Compare commits

...

104 Commits
2086 ... 3221

Author SHA1 Message Date
Greg Johnston
a10ab613b8 fix: support complex punctuated attribute keys in attr: syntax (closes #3221) 2024-11-08 15:53:08 -05:00
Greg Johnston
0eebe9e289 fix: only register async work with transition if it isn't already done (closes #3197) (#3209) 2024-11-08 09:04:52 -05:00
Greg Johnston
2abbdb6594 fix: complete navigation after rendering fallback (closes #3199) (#3208) 2024-11-08 09:04:40 -05:00
dependabot[bot]
8f8f3e23e4 chore(deps): bump tokio from 1.41.0 to 1.41.1 (#3207)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.41.0 to 1.41.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.41.0...tokio-1.41.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-07 19:16:59 -05:00
Saber Haj Rabiee
aab952357e Dependabot, Attemp #2 (#3204)
* fix: remove examples and benchmarks from dependabot search path

* chore: update/upgrade deps to prevent dependabot PR pollution at first
run

* fix: increase number of pull requests from dependabot as the workspace
is pretty big

* fix: revert rkyv version as it was unexpectedly downgraded

* fix: tower in example
2024-11-07 10:55:57 -08:00
Saber Haj Rabiee
f1ebf77fa6 fix: make free space for ci workflows before running them (#3206) 2024-11-07 10:55:42 -08:00
Darwin Boersma
5cc2f3858d Add futures-executor feature for any_spawner (#3195)
Signed-off-by: Darwin Boersma <darwin@sadlark.com>
2024-11-05 10:11:34 -08:00
Tommy Yu
8252655959 Updated tests for #3182 (#3194)
* fix: ensure we check memos the first time a dependency uses them, even if the dependency always runs on its first run (closes #3181)

* Correct expected counter values down due to #3182

- As #3182 fixed the issue where superfluous resource fetches happened
  when hydration happened inside a nested component, the expected values
  for the counters are down to where they actually are supposed to be.

---------

Co-authored-by: Greg Johnston <greg.johnston@gmail.com>
2024-11-04 18:12:54 -08:00
Saber Haj Rabiee
14e47e87ba chore: add Cargo.lock (closes #2881) (#3192) 2024-11-04 16:21:35 -05:00
Greg Johnston
4a8cfad7c5 chore(ci): suppress warnings about unused code in example tests (#3193) 2024-11-04 13:08:50 -05:00
Louis Dispa
d9f52dad76 feat: implement rendering traits for fixed-size arrays (#3174) 2024-11-04 12:26:43 -05:00
Greg Johnston
3a8508df6c rc1 2024-11-03 20:19:57 -05:00
Daniëlle Huisman
865c6df483 wasm-bindgen 0.2.95 (#3186) 2024-11-03 20:19:21 -05:00
Greg Johnston
c1d7f0f8d1 fix: exclude excluded server fn paths instead of unregistering them (closes #3150, #3175) (#3176) 2024-11-03 20:02:11 -05:00
zakstucke
8c2dd73b70 chore: expose internals of SerializedDataId and SsrSharedContext to allow creating custom hydration contexts (#3145) 2024-11-03 19:55:59 -05:00
Greg Johnston
d5894555cc fix: allow !Send errors in Actix extract() (#3189) 2024-11-03 19:53:24 -05:00
Enzo Nocera
2ef1723607 Makes the wasm32-wasip1/2 target a first-class citizen for Leptos's Server-Side (#3063)
* feat: WIP wasi integrations crate

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(server_fn): add generic types

This commit adds `From` implementations for the
`Req` and `Res` types using abstraction that are deemed
"platform-agnostic".

Indeed, both the `http` and `bytes` crates contains types
that allows us to represent HTTP Request and Response,
while being capable to target unconventional platforms
(they even have `no-std` support). This allows the
server_fn functions to target new platforms,
for example, the `wasm32-wasip*` targets.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(server_fn): generic types cleanup

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(integrations/wasi): make WASI a first-class citizen of leptos server-side

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* WIP: chore(any_spawner): make the futures::Executor runable

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* fix(server_fn): include `generic` in axum.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(any_spawner): some clippy suggestions

I ran clippy in really annoying mode since I am still
learning Rust and I want to write clean idiomatic code.
I took suggestions that I thought made sense, if any
maintainers think those are *too much*, I can relax
those changes:

* Use `core` instead of `std` to ease migration to `no_std`
  (https://rust-lang.github.io/rust-clippy/master/index.html#/std_instead_of_core)
* Add documentation on exported types and statics
* Bring some types in, with `use`
* Add `#[non_exhaustive]` on types we are not sure we
  won't extend (https://rust-lang.github.io/rust-clippy/master/index.html#exhaustive_enums)
* Add `#[inline]` to help the compiler when doing
  cross-crate compilation and Link-Time optimization
  is not enabled. (https://rust-lang.github.io/rust-clippy/master/index.html#/missing_inline_in_public_items)
* Use generic types instead of anonymous `impl` params
  so callers can use the `::<>` turbofish syntax (https://rust-lang.github.io/rust-clippy/master/index.html#/impl_trait_in_params)

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(leptos_wasi): fine-tune linter and clean-up

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(leptos_wasi): better handling of server fn with form

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: cargo fmt

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: remove custom clippy

Remove clippy crate rules since it
seems to make tests fails in tests.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: use `wasi` crate

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: revert changes to any_spawner

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: simpler crate features + cleanup

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(any_spawner): add local custom executor

This commit adds a single-thread "local"
custom executor, which is useful for environments
like `wasm32` targets.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(leptos_wasi): async runtime

This commit adds a single-threaded
async runtime for `wasm32-wasip*`
targets.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(leptos_wasi): error handling

This commit adds error types for the users
to implement better error handling.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: migrate integration off-tree

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): fix formatting

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: remove ref to leptos_wasi in Cargo.toml

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): fix fmt

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): remove explicit into_inter()

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): make generic mutually exclusive with other options

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

---------

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>
2024-11-02 09:44:50 -07:00
Greg Johnston
13f7387d45 fix: Oco equality check (closes #3178) (#3180) 2024-11-01 14:55:49 -04:00
Greg Johnston
0a13f7c08c chore: reexport unescape (closes #3177) (#3179) 2024-11-01 14:55:37 -04:00
Greg Johnston
7c83904aea Merge pull request #3173 from leptos-rs/store-tweaks
Store tweaks
2024-10-30 20:33:10 -04:00
Greg Johnston
6e13ff9787 feat: impl Into<Field<T>> for Store<T> (closes #3102) 2024-10-28 20:28:09 -04:00
Greg Johnston
234d138f03 chore: remove log 2024-10-28 20:17:23 -04:00
Greg Johnston
97110cd5ac chore: remove Then 2024-10-28 20:17:23 -04:00
Greg Johnston
5acc1b1a5a chore: rename .iter() to .iter_unkeyed() for clarity 2024-10-28 20:16:54 -04:00
Nicolas Cura
f3987246cb docs: remove duplicated "calls" word (#3171) 2024-10-28 20:04:33 -04:00
Greg Johnston
e5149fb348 fix: correctly track inner subfields on Field (closes #3169) (#3170) 2024-10-28 20:04:16 -04:00
Greg Johnston
d67ff03568 chore: fix leptos_dom reexports (closes #3166) (#3168) 2024-10-27 21:12:41 -04:00
Greg Johnston
1dbca3005d Merge pull request #3163 from leptos-rs/undep-mp
chores
2024-10-25 17:47:14 -04:00
Greg Johnston
af61be0c72 fix: correctly reset classes when using Option<T> (#3164) 2024-10-25 17:47:00 -04:00
Johannes Heuel
76facf9539 feat: improve tailwind config to also catch dynamic classes (#3143) 2024-10-25 14:06:19 -04:00
Tommy Yu
0e73d18d7b ci: regression tests for double suspense/double resource fetch (#3103)
* test: first cut of the instrumented suspense_tests

- Based on initial concepts developed for reproducing #2937 and others,
  streamlined and instrumented for e2e testing and refined for inclusion
  as a standalone module to be plugged into some other App.

* First cut of the fixtures and tests

* Actually make it work properly

- Instead of using examples, just feed it the table because examples
  will rerun the whole scenario from scratch, which isn't what we are
  trying to test here.
- Provide a basic example with item listing to show how this will work.

* Use ticketing system to disambiguate CSR calls

- Keep all SSR calls on ticket 0 as a means to disambiguate them from
  CSR calls.  For the mean time the focus of tests isn't on that
  behavior but this may be modified when suitable.

* Update the baseline fixtures

- Given the new understanding, the scenerios all being the baseline
  tests they are now moved into one file.
- Have the checks against all calls at once for better diff output,
  and reword the new scenerios into more idomatic gherkin.
- Streamline the steps and provide additional ones that will help with
  feature definitions.

* Translate the reproduction steps into Gherkin

* Comment out logging to avoid output interference.

* Be able to reset CSR counters everywhere

- Done by providing a button directly on the top level component with
  the navigation footer.  This will be useful for the next test.

* Test showing difference between hydrate and CSR

- Specifically, under hydrated load, resources that shouldn't need
  refetching gets refetched, while CSR does not show this issue.
2024-10-25 14:05:42 -04:00
Greg Johnston
d306a15f86 fix: avoid deadlocking if can't take Memo write lock (closes #3158) (#3160) 2024-10-25 13:57:44 -04:00
Greg Johnston
bf95648dc9 chore: clippy doc comment length 2024-10-25 13:56:02 -04:00
Greg Johnston
00edfc0e0a chore: undeprecate MaybeProp 2024-10-25 13:49:19 -04:00
zakstucke
396327b667 feat: Option<T> read-like traits & deprecate MaybeSignal (#3098) 2024-10-25 13:41:26 -04:00
zakstucke
a437289f81 feat: impl IntoStyle for Option<impl IntoStyle & Oco (#3119) 2024-10-25 13:31:07 -04:00
zakstucke
58e7897db6 fix: From<> impls between ArcLocalResource and LocalResource (#3144) 2024-10-25 13:30:18 -04:00
Greg Johnston
1be1f41fba fix: restore array syntax for setting multiple classes dynamically (closes #3151) (#3152) 2024-10-23 20:10:04 -04:00
Greg Johnston
7b8cd90a6e 0.7.0-rc0 2024-10-21 21:16:20 -04:00
Greg Johnston
d0ef7b904d feat: add OptionalParamSegment (closes #2896) (#3140) 2024-10-21 21:15:14 -04:00
Greg Johnston
7904e0c395 fix: unregister server functions whose paths are in excluded routes (closes #2735) (#3138) 2024-10-21 09:14:36 -04:00
Greg Johnston
7b4c470155 perf: type erasure in router (#3107) 2024-10-20 20:07:14 -04:00
Greg Johnston
98eccc9eb8 perf: make LeptosOptions lighter-weight to clone (closes #3036) (#3136) 2024-10-20 20:05:29 -04:00
Greg Johnston
70d06e2716 feat: Action::clear() to clear action value (closes #2364) (#3135) 2024-10-20 16:29:05 -04:00
PenguinWithATie
67c3bf2478 chore: add tachys::view::fragment::Fragment to prelude (#3125) 2024-10-20 14:15:15 -04:00
Corvus
f3aaae857a feat: allow axum to serve precompressed files (#3133) 2024-10-19 20:47:35 -04:00
Greg Johnston
d727e53dd6 chore(ci): reduce tachys feature set combinations (#3131) 2024-10-19 20:45:49 -04:00
zakstucke
e4543ab5df feat: new nostrip: prop prefix to pass Option<T> directly when prop(optional) (#3105) 2024-10-19 15:41:51 -04:00
Greg Johnston
1ca0f4430c feat: use View Transition API for router animations (#3112) 2024-10-19 15:41:20 -04:00
Joaquim Pedro França Simão
b59fa11853 feat: add two-way data binding support for stores (#3115) 2024-10-19 15:39:45 -04:00
Greg Johnston
e55f08e017 feat: expose use_matched() (closes #3124) (#3126) 2024-10-18 16:12:41 -04:00
zakstucke
fa1939e5b2 chore: From<ArcResource> for ArcResource and AsyncDerived (#3121) 2024-10-18 16:12:11 -04:00
zakstucke
8b2f0eaf44 fix: do not warn when reading resources in effect outside Suspense (#3118) 2024-10-18 15:24:09 -04:00
Chris
b118d69281 fix: remove unused Params attribute params (#3123)
See 1966 for original PR on older version
2024-10-18 15:20:45 -04:00
stefnotch
ee66f6c395 Add support for user-supplied executors (#3091) 2024-10-16 06:24:07 -07:00
Greg Johnston
eba08ad592 fix: don't render empty string as a space in unescaped elements (closes #3120) (#3122) 2024-10-15 18:57:09 -04:00
Greg Johnston
4833b4e287 fix: avoid double-polling synchronously-available Suspend (closes #3113) (#3114) 2024-10-15 08:49:40 -04:00
Greg Johnston
9d1be64e4d chore: publish stores (#3110) 2024-10-14 10:18:38 -04:00
benwis
d6e6cd3be0 v0.7.0gamma3 2024-10-14 05:01:19 -07:00
stefnotch
70476f9277 feat: add support for async-executor from smol-rs (#3090) 2024-10-14 07:57:19 -04:00
zakstucke
d8ddfc26e9 perf: use the Track trait for the Signal wrapper. (#3076) 2024-10-12 20:29:03 -04:00
stefnotch
c8acc3e8bd fix: correctly support local pools for futures-executor (#3089) 2024-10-12 20:13:50 -04:00
zakstucke
547442243b impl IntoClass for Option<impl IntoClass> (#3104) 2024-10-12 05:03:53 -07:00
Greg Johnston
6e58266f54 feat: support set_is_routing/RoutingProgress for nested routes (#3101) 2024-10-11 19:05:33 -04:00
Greg Johnston
f0cd0fb41d feat: condense Router/Routes base prop into one (#3100) 2024-10-11 14:06:11 -04:00
Daniil Polyakov
7585faf57e fix: use full path to Result in Params derive (#3096) 2024-10-10 15:20:38 -04:00
zakstucke
da7f6a34e8 chore: expose AnyView in prelude (#3099) 2024-10-10 15:20:24 -04:00
Greg Johnston
4f7fa41262 fix: don't on WASM server targets unless you actually try to generate static routes (closes #3094) (#3097) 2024-10-10 15:20:04 -04:00
Greg Johnston
4becfa39ca correct version number 2024-10-10 09:13:39 -04:00
Greg Johnston
f8388b122d fix: avoid reentering lock when initializing nested keyed store fields (closes #3086) (#3087) 2024-10-10 08:53:28 -04:00
Greg Johnston
f57a57b92b feat: restore AnimatedShow for 0.7 (#3084) 2024-10-10 08:53:05 -04:00
vsuryamurthy
f0bcbd9cfe remove unused dependencies leptos_axum and leptos_router (#2960)
* remove unused dependencies leptos_axum and leptos_router

* cargo fmt

* Restore http::Uri under default feature

* use axum re-exported headers instead of http directly

---------

Co-authored-by: Greg Johnston <greg.johnston@gmail.com>
2024-10-10 04:29:11 -07:00
Greg Johnston
115477ef1d chore: remove unused code from leptos package (#3085) 2024-10-10 04:23:37 -07:00
Greg Johnston
832b9cb321 chore: pin wasm-bindgen to 0.2.93 to fix example builds (#3088) 2024-10-09 22:56:05 -04:00
Greg Johnston
b0150ceeec fix: missing Copy/Clone implementations for OnceResource (#3080) 2024-10-09 19:33:33 -04:00
dependabot[bot]
af8df34360 chore(deps): bump denoland/setup-deno from 1 to 2 (#3081)
Bumps [denoland/setup-deno](https://github.com/denoland/setup-deno) from 1 to 2.
- [Release notes](https://github.com/denoland/setup-deno/releases)
- [Commits](https://github.com/denoland/setup-deno/compare/v1...v2)

---
updated-dependencies:
- dependency-name: denoland/setup-deno
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-09 18:50:31 -04:00
Greg Johnston
b2e6185b22 fix: don't escape script/style/textarea in InertHtml (closes #3078) (#3079) 2024-10-09 10:07:40 -04:00
Greg Johnston
d2bfb3080b Merge pull request #3077 from leptos-rs/router-fixes
Router fixes
2024-10-09 07:33:24 -04:00
Greg Johnston
72ebd17042 fix: only set browser URL if it matches current router URL (closes #2979( 2024-10-08 22:12:18 -04:00
Greg Johnston
e2f0b4deeb fix: prevent simultaneous \query_signal\ writes from canceling each other (closes #2369) 2024-10-08 22:12:02 -04:00
Greg Johnston
57c07e9aec feat: enable faster compile times with RUSTFLAGS="--cfg erase_components (#2905) 2024-10-08 17:03:40 -04:00
Greg Johnston
0835066bc0 chore: re-add regression tests from #2639 (#3073) 2024-10-08 17:02:18 -04:00
webmstk
656e83fe24 docs: fix comment for set_interval helper (#3074) 2024-10-08 13:30:17 -04:00
Greg Johnston
ad0252ecfd fix: inconsistencies in check for latest version in actions (#3070) 2024-10-07 21:02:02 -04:00
Greg Johnston
77f05c6f4e fix: add HEAD support for Actix in leptos_routes (closes #2885) (#3069) 2024-10-07 21:01:46 -04:00
zakstucke
a4ea491dc0 feat: add Read/ReadUntracked/Track for Signal/MaybeSignal/MaybeProp (#3031) 2024-10-07 19:55:07 -04:00
Greg Johnston
3c89b9c930 feat: add an optimized OnceResource and use it to rebuild Await (#3064) 2024-10-06 20:47:22 -04:00
Greg Johnston
93d7ba0d5f fix: add SVG <use> (closes #3065) (#3067) 2024-10-06 20:47:06 -04:00
Greg Johnston
e188993800 fix: remove unnecessary Send/Sync bounds on LocalResource (#3061) 2024-10-04 16:16:24 -04:00
Greg Johnston
c1dc8c7629 Merge pull request #3062 from leptos-rs/into-render
feat: add `IntoRender` for rendering custom data
2024-10-04 14:43:55 -04:00
Greg Johnston
ab9de1b8c0 chore: remove unused variable 2024-10-04 13:56:38 -04:00
Greg Johnston
b39985d9b8 fix: only use IntoAttributeValue for parts of view that are actually attribute values 2024-10-04 13:38:09 -04:00
Greg Johnston
5e8e93001d docs: IntoRender and IntoAttributeValue 2024-10-04 13:25:57 -04:00
Greg Johnston
a4ed0cbe5b feat: add IntoAttributeValue for rendering arbitrary attribute values 2024-10-04 13:24:39 -04:00
Greg Johnston
422fe9f43b feat: add IntoRender for rendering arbitrary types 2024-10-04 13:13:23 -04:00
kczimm
36df36e16c feat: allow ParamsMap to support multiple values per key (closes #2882) (#2966)
Co-authored-by: Greg Johnston <greg.johnston@gmail.com>
2024-10-03 18:35:50 -04:00
Chris
95fc79034b chore: dead router::router module from 0.6 (#2943) 2024-10-02 19:35:49 -04:00
Greg Johnston
7403e4084f Merge pull request #3040 from mahdi739/double-ended-iterator-for-stores
Double-ended-iterator-for-stores
2024-10-02 19:19:40 -04:00
jk
8feee5e5d7 Migrate rkyv 0.8.x (#3054) 2024-10-02 10:03:20 -07:00
Greg Johnston
e6da266b4f Merge pull request #3050 from leptos-rs/2086
Module restructuring and docs cleanup
2024-10-01 21:23:47 -04:00
Greg Johnston
dcc7865989 fix: remove r# from raw attribute names in InertHtml (closes #3049) (#3058) 2024-10-01 20:18:29 -04:00
Greg Johnston
c47893ad60 fix: <textarea> does not parse its children as HTML, like <script> and <style> (#3052) 2024-10-01 19:39:10 -04:00
Mahdi
4c3bcaa68d implement DoubleEndedIterator for StoreFieldKeyedIter 2024-09-28 17:15:43 +03:30
Mahdi
fe060617d2 implement DoubleEndedIterator for StoreFieldIter 2024-09-28 17:14:45 +03:30
203 changed files with 12409 additions and 4402 deletions

View File

@@ -7,7 +7,6 @@ updates:
- package-ecosystem: "cargo"
directories:
- "/"
- "/examples/*"
- "/benchmarks"
schedule:
interval: "daily"
open-pull-requests-limit: 10

View File

@@ -19,6 +19,19 @@ jobs:
name: Run ${{ inputs.cargo_make_task }} (${{ inputs.toolchain }})
runs-on: ubuntu-latest
steps:
- name: Free Disk Space
run: |
echo "Disk space before cleanup:"
df -h
sudo rm -rf /usr/local/.ghcup
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/lib/android/sdk/ndk
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
sudo apt-get clean
echo "Disk space after cleanup:"
df -h
# Setup environment
- uses: actions/checkout@v4
- name: Setup Rust
@@ -94,7 +107,7 @@ jobs:
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

6
.gitignore vendored
View File

@@ -3,7 +3,9 @@ dist
pkg
comparisons
blob.rs
Cargo.lock
**/projects/**/Cargo.lock
**/examples/**/Cargo.lock
**/benchmarks/**/Cargo.lock
**/*.rs.bk
.DS_Store
.idea
@@ -11,4 +13,4 @@ Cargo.lock
.envrc
.vscode
vendor
vendor

4474
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -40,36 +40,36 @@ members = [
exclude = ["benchmarks", "examples", "projects"]
[workspace.package]
version = "0.7.0-gamma"
version = "0.7.0-rc1"
edition = "2021"
rust-version = "1.76"
[workspace.dependencies]
throw_error = { path = "./any_error/", version = "0.2.0-gamma" }
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-gamma" }
leptos = { path = "./leptos", version = "0.7.0-gamma" }
leptos_config = { path = "./leptos_config", version = "0.7.0-gamma" }
leptos_dom = { path = "./leptos_dom", version = "0.7.0-gamma" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-gamma" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-gamma" }
leptos_macro = { path = "./leptos_macro", version = "0.7.0-gamma" }
leptos_router = { path = "./router", version = "0.7.0-gamma" }
leptos_router_macro = { path = "./router_macro", version = "0.7.0-gamma" }
leptos_server = { path = "./leptos_server", version = "0.7.0-gamma" }
leptos_meta = { path = "./meta", version = "0.7.0-gamma" }
next_tuple = { path = "./next_tuple", version = "0.1.0-gamma" }
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-gamma" }
reactive_stores = { path = "./reactive_stores", version = "0.1.0-gamma" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-gamma" }
server_fn = { path = "./server_fn", version = "0.7.0-gamma" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-gamma" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-gamma" }
tachys = { path = "./tachys", version = "0.1.0-gamma" }
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

View File

@@ -1,6 +1,6 @@
[package]
name = "throw_error"
version = "0.2.0-gamma"
version = "0.2.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -10,4 +10,4 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
pin-project-lite = "0.2.14"
pin-project-lite = "0.2.15"

View File

@@ -9,22 +9,25 @@ description = "Spawn asynchronous tasks in an executor-independent way."
edition.workspace = true
[dependencies]
futures = "0.3.30"
glib = { version = "0.20.0", optional = true }
thiserror = "1.0"
tokio = { version = "1.39", optional = true, default-features = false, features = [
async-executor = { version = "1.13.1", optional = true }
futures = "0.3.31"
glib = { version = "0.20.5", optional = true }
thiserror = "2.0"
tokio = { version = "1.41", optional = true, default-features = false, features = [
"rt",
] }
tracing = { version = "0.1.40", optional = true }
wasm-bindgen-futures = { version = "0.4.42", optional = true }
wasm-bindgen-futures = { version = "0.4.45", 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"]

View File

@@ -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);
}

View 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);
}

View 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);
}

View File

@@ -10,4 +10,4 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
pin-project-lite = "0.2.14"
pin-project-lite = "0.2.15"

View File

@@ -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};

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
use counter::*;
use leptos::mount::mount_to;
use leptos::prelude::*;

View File

@@ -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()

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
use counter_without_macros::counter;
use leptos::{prelude::*, task::tick};
use pretty_assertions::assert_eq;

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
use directives::App;
use leptos::{prelude::*, task::tick};
use wasm_bindgen::JsCast;

View File

@@ -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 |_| {

View File

@@ -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>

View File

@@ -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)?

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(),

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
use portal::App;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;

View File

@@ -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>

View File

@@ -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,7 +70,7 @@ pub fn RouterExample() -> impl IntoView {
// You can define other routes in their own component.
// Routes implement the MatchNestedRoutes
#[component]
#[component(transparent)]
pub fn ContactRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("") view=ContactList>

View File

@@ -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);

View File

@@ -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"]

View File

@@ -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};

View File

@@ -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)

View File

@@ -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>

View File

@@ -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)?

View File

@@ -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::*;

View File

@@ -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::*;

View File

@@ -1,9 +1,9 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use chrono::{Local, NaiveDate};
use leptos::logging::warn;
use leptos::prelude::*;
use reactive_stores::{Field, Patch, Store};
use reactive_stores_macro::{Patch, Store};
use serde::{Deserialize, Serialize};
// ID starts higher than 0 because we have a few starting todos by default
@@ -110,11 +110,7 @@ pub fn App() -> impl IntoView {
// directly implements IntoIterator, so we can use it in <For/> and
// it will manage reactivity for the store fields correctly
<For
each=move || {
leptos::logging::log!("RERUNNING FOR CALCULATION");
store.todos()
}
each=move || store.todos()
key=|row| row.id().get()
let:todo
>

View File

@@ -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",

View File

@@ -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 |

View File

@@ -0,0 +1,187 @@
@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 | 1 |
| 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 | 0 |
| inspect_item_root | 1 |
| 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 | 0 |
| inspect_item_root | 2 |
| 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 | 1 |
| 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 | 2 |
| 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 | 1 |
| inspect_item_root | 1 |
| inspect_item_field | 0 |
# The following tests previously showed the clear difference between
# hydration and CSR, where hydration resulting in extra server API
# calls via the resource while CSR did not suffer from the issue.
# With #3182 merged the issue is corrected, going up to components
# specified by the parent route should no longer result in the
# superfluous fetches for resources needed by component about to be
# unmounted.
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 | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
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 |
# Further two sets for good measure.
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 | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
Scenario: Emulate the same csr counter reset, for Target 41#.
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 |

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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(

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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>

View 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);
}

View File

@@ -1,4 +1,5 @@
pub mod app;
mod instrumented;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]

View File

@@ -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)

View File

@@ -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");
});

View File

@@ -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>
}
}

View File

@@ -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)?

View File

@@ -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: {},

View File

@@ -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");
});

View File

@@ -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>

View File

@@ -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: [],
}

View File

@@ -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");
});

View File

@@ -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>
}
}

View File

@@ -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: {},

View File

@@ -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>| {

View File

@@ -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)?

View File

@@ -16,15 +16,15 @@ leptos_router = { path = "../../router" }
leptos_integration_utils = { path = "../../integrations/utils", optional = true }
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tower = { version = "0.5.1", features = ["util"], optional = true }
tower-http = { version = "0.6.1", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }
http = { version = "1.1" }
sqlx = { version = "0.8.0", features = [
"runtime-tokio-rustls",
"sqlite",
], optional = true }
thiserror = "1.0"
thiserror = "2.0"
wasm-bindgen = "0.2.93"
[features]

View File

@@ -9,7 +9,7 @@ use leptos::{
hydration::{AutoReload, HydrationScripts},
prelude::*,
};
use tower::ServiceExt;
use tower::util::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_or_index_handler(

58
flake.lock generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
[package]
name = "hydration_context"
version = "0.2.0-gamma"
version = "0.2.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -12,12 +12,12 @@ edition.workspace = true
[dependencies]
throw_error = { workspace = true }
or_poisoned = { workspace = true }
futures = "0.3.30"
futures = "0.3.31"
serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = { version = "0.2.93", optional = true }
js-sys = { version = "0.3.69", optional = true }
once_cell = "1.19"
pin-project-lite = "0.2.14"
wasm-bindgen = { version = "0.2.95", optional = true }
js-sys = { version = "0.3.72", optional = true }
once_cell = "1.20"
pin-project-lite = "0.2.15"
[features]
browser = ["dep:wasm-bindgen", "dep:js-sys"]

View File

@@ -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()

View File

@@ -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 {

View File

@@ -9,10 +9,10 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
actix-http = "3.8"
actix-http = "3.9"
actix-files = "0.6"
actix-web = "4.8"
futures = "0.3.30"
actix-web = "4.9"
futures = "0.3.31"
any_spawner = { workspace = true, features = ["tokio"] }
hydration_context = { workspace = true }
leptos = { workspace = true, features = ["nonce", "ssr"] }
@@ -24,7 +24,7 @@ server_fn = { workspace = true, features = ["actix"] }
serde_json = "1.0"
parking_lot = "0.12.3"
tracing = { version = "0.1", optional = true }
tokio = { version = "1.39", features = ["rt", "fs"] }
tokio = { version = "1.41", features = ["rt", "fs"] }
send_wrapper = "0.6.0"
dashmap = "6"
once_cell = "1"

View File

@@ -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},
@@ -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
}

View File

@@ -11,13 +11,11 @@ edition.workspace = true
[dependencies]
any_spawner = { workspace = true, features = ["tokio"] }
hydration_context = { workspace = true }
axum = { version = "0.7.5", default-features = false, features = [
axum = { version = "0.7.7", default-features = false, features = [
"matched-path",
] }
dashmap = "6"
futures = "0.3.30"
http = "1.1"
http-body-util = "0.1.2"
futures = "0.3.31"
leptos = { workspace = true, features = ["nonce", "ssr"] }
server_fn = { workspace = true, features = ["axum-no-default"] }
leptos_macro = { workspace = true, features = ["axum"] }
@@ -26,15 +24,14 @@ 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-http = "0.5.2"
tokio = { version = "1.41", default-features = false }
tower = { version = "0.5.1", features = ["util"] }
tower-http = "0.6.1"
tracing = { version = "0.1.40", optional = true }
[dev-dependencies]
axum = "0.7.5"
tokio = { version = "1.39", features = ["net", "rt-multi-thread"] }
axum = "0.7.7"
tokio = { version = "1.41", features = ["net", "rt-multi-thread"] }
[features]
wasm = []

View File

@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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,

View File

@@ -9,7 +9,7 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
futures = "0.3.30"
futures = "0.3.31"
hydration_context = { workspace = true }
leptos = { workspace = true, features = ["nonce"] }
leptos_meta = { workspace = true, features = ["ssr"] }

View File

@@ -11,7 +11,7 @@ edition.workspace = true
[dependencies]
throw_error = { workspace = true }
any_spawner = { workspace = true, features = ["wasm-bindgen"] }
any_spawner = { workspace = true, features = ["wasm-bindgen", "futures-executor"] }
base64 = { version = "0.22.1", optional = true }
cfg-if = "1.0"
hydration_context = { workspace = true }
@@ -28,11 +28,11 @@ paste = "1.0"
rand = { version = "0.8.5", optional = true }
reactive_graph = { workspace = true, features = ["serde"] }
rustc-hash = "2.0"
tachys = { workspace = true, features = ["reactive_graph", "oco"] }
thiserror = "1.0"
tachys = { workspace = true, features = ["reactive_graph", "reactive_stores", "oco"] }
thiserror = "2.0"
tracing = { version = "0.1.40", optional = true }
typed-builder = "0.19.1"
typed-builder-macro = "0.19.1"
typed-builder = "0.20.0"
typed-builder-macro = "0.20.0"
serde = "1.0"
serde_json = { version = "1.0", optional = true }
server_fn = { workspace = true, features = [
@@ -40,15 +40,15 @@ server_fn = { workspace = true, features = [
"browser",
"url",
] }
web-sys = { version = "0.3.70", features = [
web-sys = { version = "0.3.72", features = [
"ShadowRoot",
"ShadowRootInit",
"ShadowRootMode",
] }
wasm-bindgen = "0.2.93"
wasm-bindgen = "0.2.95"
serde_qs = "0.13.0"
slotmap = "1.0"
futures = "0.3.30"
futures = "0.3.31"
send_wrapper = "0.6.0"
[features]

View File

@@ -1,59 +0,0 @@
#![allow(deprecated)]
use crate::TextProp;
use std::rc::Rc;
/// A collection of additional HTML attributes to be applied to an element,
/// each of which may or may not be reactive.
#[derive(Clone)]
#[repr(transparent)]
#[deprecated = "Most uses of `AdditionalAttributes` can be replaced with `#[prop(attrs)]` \
and the `attr:` syntax. If you have a use case that still requires `AdditionalAttributes`, please \
open a GitHub issue here and share it: https://github.com/leptos-rs/leptos"]
pub struct AdditionalAttributes(pub(crate) Rc<[(String, TextProp)]>);
impl<I, T, U> From<I> for AdditionalAttributes
where
I: IntoIterator<Item = (T, U)>,
T: Into<String>,
U: Into<TextProp>,
{
fn from(value: I) -> Self {
Self(
value
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
)
}
}
impl Default for AdditionalAttributes {
fn default() -> Self {
Self([].into_iter().collect())
}
}
/// Iterator over additional HTML attributes.
#[repr(transparent)]
pub struct AdditionalAttributesIter<'a>(
std::slice::Iter<'a, (String, TextProp)>,
);
impl<'a> Iterator for AdditionalAttributesIter<'a> {
type Item = &'a (String, TextProp);
#[inline(always)]
fn next(&mut self) -> Option<Self::Item> {
self.0.next()
}
}
impl<'a> IntoIterator for &'a AdditionalAttributes {
type Item = &'a (String, TextProp);
type IntoIter = AdditionalAttributesIter<'a>;
fn into_iter(self) -> Self::IntoIter {
AdditionalAttributesIter(self.0.iter())
}
}

View File

@@ -1,12 +1,15 @@
use crate::{ChildrenFn, Show};
use crate::{children::ChildrenFn, component, control_flow::Show, IntoView};
use core::time::Duration;
use leptos::component;
use leptos_dom::{helpers::TimeoutHandle, IntoView};
use leptos_dom::helpers::TimeoutHandle;
use leptos_macro::view;
use leptos_reactive::{
create_render_effect, on_cleanup, signal_prelude::*, store_value,
StoredValue,
use reactive_graph::{
effect::RenderEffect,
owner::{on_cleanup, StoredValue},
signal::RwSignal,
traits::{Get, GetUntracked, GetValue, Set, SetValue},
wrappers::read::Signal,
};
use tachys::prelude::*;
/// A component that will show its children when the `when` condition is `true`.
/// Additionally, you need to specify a `hide_delay`. If the `when` condition changes to `false`,
@@ -16,10 +19,10 @@ use leptos_reactive::{
///
/// ```rust
/// # use core::time::Duration;
/// # use leptos::*;
/// # use leptos::prelude::*;
/// # #[component]
/// # pub fn App() -> impl IntoView {
/// let show = create_rw_signal(false);
/// let show = RwSignal::new(false);
///
/// view! {
/// <div
@@ -50,7 +53,7 @@ pub fn AnimatedShow(
children: ChildrenFn,
/// If the component should show or not
#[prop(into)]
when: MaybeSignal<bool>,
when: Signal<bool>,
/// Optional CSS class to apply if `when == true`
#[prop(optional)]
show_class: &'static str,
@@ -60,15 +63,15 @@ pub fn AnimatedShow(
/// The timeout after which the component will be unmounted if `when == false`
hide_delay: Duration,
) -> impl IntoView {
let handle: StoredValue<Option<TimeoutHandle>> = store_value(None);
let cls = create_rw_signal(if when.get_untracked() {
let handle: StoredValue<Option<TimeoutHandle>> = StoredValue::new(None);
let cls = RwSignal::new(if when.get_untracked() {
show_class
} else {
hide_class
});
let show = create_rw_signal(when.get_untracked());
let show = RwSignal::new(when.get_untracked());
create_render_effect(move |_| {
let eff = RenderEffect::new(move |_| {
if when.get() {
// clear any possibly active timer
if let Some(h) = handle.get_value() {
@@ -93,6 +96,7 @@ pub fn AnimatedShow(
if let Some(Some(h)) = handle.try_get_value() {
h.clear();
}
drop(eff);
});
view! {

View File

@@ -1,10 +1,8 @@
use crate::Suspense;
use leptos_dom::IntoView;
use crate::{prelude::Suspend, suspense_component::Suspense, IntoView};
use leptos_macro::{component, view};
use leptos_reactive::{
create_blocking_resource, create_local_resource, create_resource,
store_value, Serializable,
};
use leptos_server::ArcOnceResource;
use reactive_graph::prelude::ReadUntracked;
use serde::{de::DeserializeOwned, Serialize};
#[component]
/// Allows you to inline the data loading for an `async` block or
@@ -15,11 +13,8 @@ use leptos_reactive::{
/// Adding `let:{variable name}` to the props makes the data available in the children
/// that variable name, when resolved.
/// ```
/// # use leptos_reactive::*;
/// # use leptos_macro::*;
/// # use leptos_dom::*; use leptos::*;
/// # use leptos::prelude::*;
/// # if false {
/// # let runtime = create_runtime();
/// async fn fetch_monkeys(monkey: i32) -> i32 {
/// // do some expensive work
/// 3
@@ -27,29 +22,23 @@ use leptos_reactive::{
///
/// view! {
/// <Await
/// future=|| fetch_monkeys(3)
/// future=fetch_monkeys(3)
/// let:data
/// >
/// <p>{*data} " little monkeys, jumping on the bed."</p>
/// </Await>
/// }
/// # ;
/// # runtime.dispose();
/// # }
/// ```
pub fn Await<T, Fut, FF, VF, V>(
/// A function that returns the [`Future`](std::future::Future) that
/// will the component will `.await` before rendering.
future: FF,
/// If `true`, the component will use [`create_blocking_resource`], preventing
pub fn Await<T, Fut, Chil, V>(
/// A [`Future`](std::future::Future) that will the component will `.await`
/// before rendering.
future: Fut,
/// If `true`, the component will create a blocking resource, preventing
/// the HTML stream from returning anything before `future` has resolved.
#[prop(optional)]
blocking: bool,
/// If `true`, the component will use [`create_local_resource`], this will
/// always run on the local system and therefore its result type does not
/// need to be `Serializable`.
#[prop(optional)]
local: bool,
/// A function that takes a reference to the resolved data from the `future`
/// renders a view.
///
@@ -58,65 +47,58 @@ pub fn Await<T, Fut, FF, VF, V>(
/// `let:` syntax to specify the name for the data variable.
///
/// ```rust
/// # use leptos::*;
/// # use leptos::prelude::*;
/// # if false {
/// # let runtime = create_runtime();
/// # async fn fetch_monkeys(monkey: i32) -> i32 {
/// # 3
/// # }
/// view! {
/// <Await
/// future=|| fetch_monkeys(3)
/// future=fetch_monkeys(3)
/// let:data
/// >
/// <p>{*data} " little monkeys, jumping on the bed."</p>
/// </Await>
/// }
/// # ;
/// # runtime.dispose();
/// # }
/// ```
/// is the same as
/// ```rust
/// # use leptos::*;
/// # use leptos::prelude::*;
/// # if false {
/// # let runtime = create_runtime();
/// # async fn fetch_monkeys(monkey: i32) -> i32 {
/// # 3
/// # }
/// view! {
/// <Await
/// future=|| fetch_monkeys(3)
/// future=fetch_monkeys(3)
/// children=|data| view! {
/// <p>{*data} " little monkeys, jumping on the bed."</p>
/// }
/// />
/// }
/// # ;
/// # runtime.dispose();
/// # }
/// ```
children: VF,
children: Chil,
) -> impl IntoView
where
Fut: std::future::Future<Output = T> + 'static,
FF: Fn() -> Fut + 'static,
V: IntoView,
VF: Fn(&T) -> V + 'static,
T: Serializable + 'static,
T: Send + Sync + Serialize + DeserializeOwned + 'static,
Fut: std::future::Future<Output = T> + Send + 'static,
Chil: FnOnce(&T) -> V + Send + 'static,
V: IntoView + 'static,
{
let res = if blocking {
create_blocking_resource(|| (), move |_| future())
} else if local {
create_local_resource(|| (), move |_| future())
} else {
create_resource(|| (), move |_| future())
};
let view = store_value(children);
let res = ArcOnceResource::<T>::new_with_options(future, blocking);
let ready = res.ready();
view! {
<Suspense fallback=|| ()>
{move || res.map(|data| view.with_value(|view| view(data)))}
{Suspend::new(async move {
ready.await;
children(res.read_untracked().as_ref().unwrap())
})}
</Suspense>
}
}

View File

@@ -41,7 +41,10 @@
//!
//! Use `SyncCallback` if the function is not `Sync` and `Send`.
use reactive_graph::owner::{LocalStorage, StoredValue};
use reactive_graph::{
owner::{LocalStorage, StoredValue},
traits::WithValue,
};
use std::{fmt, rc::Rc, sync::Arc};
/// A wrapper trait for calling callbacks.

View File

@@ -50,7 +50,7 @@ pub fn HydrationScripts(
path.parent().map(|p| p.to_path_buf()).unwrap_or_default()
})
.unwrap_or_default()
.join(&options.hash_file);
.join(options.hash_file.as_ref());
if hash_path.exists() {
let hashes = std::fs::read_to_string(&hash_path)
.expect("failed to read hash file");

View File

@@ -163,7 +163,7 @@ pub mod prelude {
form::*, hydration::*, into_view::*, mount::*, suspense::*,
};
pub use leptos_config::*;
pub use leptos_dom::{helpers::*, *};
pub use leptos_dom::helpers::*;
pub use leptos_macro::*;
pub use leptos_server::*;
pub use oco_ref::*;
@@ -174,7 +174,9 @@ pub mod prelude {
pub use server_fn::{self, ServerFnError};
pub use tachys::{
reactive_graph::{bind::BindAttribute, node_ref::*, Suspend},
view::template::ViewTemplate,
view::{
any_view::AnyView, fragment::Fragment, template::ViewTemplate,
},
};
}
pub use export_types::*;
@@ -200,10 +202,12 @@ pub mod error {
pub use throw_error::*;
}
/// Control-flow components like `<Show>` and `<For>`.
/// Control-flow components like `<Show>`, `<For>`, and `<Await>`.
pub mod control_flow {
pub use crate::{for_loop::*, show::*};
pub use crate::{animated_show::*, await_::*, for_loop::*, show::*};
}
mod animated_show;
mod await_;
mod for_loop;
mod show;
@@ -325,234 +329,3 @@ pub use tracing;
pub use wasm_bindgen;
#[doc(hidden)]
pub use web_sys;
/*mod additional_attributes;
pub use additional_attributes::*;
mod await_;
pub use await_::*;
pub use leptos_config::{self, get_configuration, LeptosOptions};
#[cfg(not(all(
target_arch = "wasm32",
any(feature = "csr", feature = "hydrate")
)))]
/// Utilities for server-side rendering HTML.
pub mod ssr {
pub use leptos_dom::{ssr::*, ssr_in_order::*};
}
pub use leptos_dom::{
self, create_node_ref, document, ev,
helpers::{
event_target, event_target_checked, event_target_value,
request_animation_frame, request_animation_frame_with_handle,
request_idle_callback, request_idle_callback_with_handle, set_interval,
set_interval_with_handle, set_timeout, set_timeout_with_handle,
window_event_listener, window_event_listener_untyped,
},
html,
html::Binding,
math, mount_to, mount_to_body, nonce, svg, window, Attribute, Class,
CollectView, Errors, EventHandlerFn, Fragment, HtmlElement, IntoAttribute,
IntoClass, IntoProperty, IntoStyle, IntoView, NodeRef, Property, View,
};
/// Types to make it easier to handle errors in your application.
pub mod error {
pub use server_fn::error::{Error, Result};
}
#[cfg(all(target_arch = "wasm32", feature = "template_macro"))]
pub use leptos_macro::template;
#[cfg(not(all(target_arch = "wasm32", feature = "template_macro")))]
pub use leptos_macro::view as template;
pub use leptos_macro::{component, island, slice, slot, view, Params};
cfg_if::cfg_if!(
if #[cfg(feature="spin")] {
pub use leptos_spin_macro::server;
} else {
pub use leptos_macro::server;
}
);
pub use leptos_reactive::*;
pub use leptos_server::{
self, create_action, create_multi_action, create_server_action,
create_server_multi_action, Action, MultiAction, ServerFnError,
ServerFnErrorErr,
};
pub use server_fn::{self, ServerFn as _};
mod error_boundary;
pub use error_boundary::*;
mod animated_show;
mod for_loop;
mod provider;
mod show;
pub use animated_show::*;
pub use for_loop::*;
pub use provider::*;
#[cfg(feature = "experimental-islands")]
pub use serde;
#[cfg(feature = "experimental-islands")]
pub use serde_json;
pub use show::*;
//pub use suspense_component::*;
mod suspense_component;
//mod transition;
#[cfg(feature = "tracing")]
#[doc(hidden)]
pub use tracing;
pub use transition::*;
#[doc(hidden)]
pub use typed_builder;
#[doc(hidden)]
pub use typed_builder::Optional;
#[doc(hidden)]
pub use typed_builder_macro;
#[doc(hidden)]
#[cfg(any(
feature = "csr",
feature = "hydrate",
feature = "template_macro"
))]
pub use wasm_bindgen; // used in islands
#[doc(hidden)]
#[cfg(any(
feature = "csr",
feature = "hydrate",
feature = "template_macro"
))]
pub use web_sys; // used in islands
mod children;
mod portal;
mod view_fn;
pub use children::*;
pub use portal::*;
pub use view_fn::*;
extern crate self as leptos;
/// A type for taking anything that implements [`IntoAttribute`].
///
/// ```rust
/// use leptos::*;
///
/// #[component]
/// pub fn MyHeading(
/// text: String,
/// #[prop(optional, into)] class: Option<AttributeValue>,
/// ) -> impl IntoView {
/// view! {
/// <h1 class=class>{text}</h1>
/// }
/// }
/// ```
pub type AttributeValue = Box<dyn IntoAttribute>;
#[doc(hidden)]
pub trait Component<P> {}
#[doc(hidden)]
pub trait Props {
type Builder;
fn builder() -> Self::Builder;
}
#[doc(hidden)]
pub trait DynAttrs {
fn dyn_attrs(self, _args: Vec<(&'static str, Attribute)>) -> Self
where
Self: Sized,
{
self
}
}
impl DynAttrs for () {}
#[doc(hidden)]
pub trait DynBindings {
fn dyn_bindings<B: Into<Binding>>(
self,
_args: impl IntoIterator<Item = B>,
) -> Self
where
Self: Sized,
{
self
}
}
impl DynBindings for () {}
#[doc(hidden)]
pub trait PropsOrNoPropsBuilder {
type Builder;
fn builder_or_not() -> Self::Builder;
}
#[doc(hidden)]
#[derive(Copy, Clone, Debug, Default)]
pub struct EmptyPropsBuilder {}
impl EmptyPropsBuilder {
pub fn build(self) {}
}
impl<P: Props> PropsOrNoPropsBuilder for P {
type Builder = <P as Props>::Builder;
fn builder_or_not() -> Self::Builder {
Self::builder()
}
}
impl PropsOrNoPropsBuilder for EmptyPropsBuilder {
type Builder = EmptyPropsBuilder;
fn builder_or_not() -> Self::Builder {
EmptyPropsBuilder {}
}
}
impl<F, R> Component<EmptyPropsBuilder> for F where F: FnOnce() -> R {}
impl<P, F, R> Component<P> for F
where
F: FnOnce(P) -> R,
P: Props,
{
}
#[doc(hidden)]
pub fn component_props_builder<P: PropsOrNoPropsBuilder>(
_f: &impl Component<P>,
) -> <P as PropsOrNoPropsBuilder>::Builder {
<P as PropsOrNoPropsBuilder>::builder_or_not()
}
#[doc(hidden)]
pub fn component_view<P>(f: impl ComponentConstructor<P>, props: P) -> View {
f.construct(props)
}
#[doc(hidden)]
pub trait ComponentConstructor<P> {
fn construct(self, props: P) -> View;
}
impl<Func, V> ComponentConstructor<()> for Func
where
Func: FnOnce() -> V,
V: IntoView,
{
fn construct(self, (): ()) -> View {
(self)().into_view()
}
}
impl<Func, V, P> ComponentConstructor<P> for Func
where
Func: FnOnce(P) -> V,
V: IntoView,
P: PropsOrNoPropsBuilder,
{
fn construct(self, props: P) -> View {
(self)(props).into_view()
}
}*/

View File

@@ -35,7 +35,7 @@ pub fn Provider<T, Chil>(
) -> impl IntoView
where
T: Send + Sync + 'static,
Chil: IntoView,
Chil: IntoView + 'static,
{
let owner = Owner::current()
.expect("no current reactive Owner found")

View File

@@ -1,30 +0,0 @@
use leptos_dom::{IntoView, View};
use std::rc::Rc;
/// New-type wrapper for the a function that returns a view with `From` and `Default` traits implemented
/// to enable optional props in for example `<Show>` and `<Suspense>`.
#[derive(Clone)]
pub struct ViewFn(Rc<dyn Fn() -> View>);
impl Default for ViewFn {
fn default() -> Self {
Self(Rc::new(|| ().into_view()))
}
}
impl<F, IV> From<F> for ViewFn
where
F: Fn() -> IV + 'static,
IV: IntoView,
{
fn from(value: F) -> Self {
Self(Rc::new(move || value().into_view()))
}
}
impl ViewFn {
/// Execute the wrapped function
pub fn run(&self) -> View {
(self.0)()
}
}

View File

@@ -1,5 +1,7 @@
#[cfg(feature = "ssr")]
use leptos::html::HtmlElement;
#[cfg(feature = "ssr")]
#[test]
fn simple_ssr_test() {
use leptos::prelude::*;
@@ -20,6 +22,7 @@ fn simple_ssr_test() {
);
}
#[cfg(feature = "ssr")]
#[test]
fn ssr_test_with_components() {
use leptos::prelude::*;
@@ -51,6 +54,7 @@ fn ssr_test_with_components() {
);
}
#[cfg(feature = "ssr")]
#[test]
fn ssr_test_with_snake_case_components() {
use leptos::prelude::*;
@@ -81,6 +85,7 @@ fn ssr_test_with_snake_case_components() {
);
}
#[cfg(feature = "ssr")]
#[test]
fn test_classes() {
use leptos::prelude::*;
@@ -98,6 +103,7 @@ fn test_classes() {
assert_eq!(rendered.to_html(), "<div class=\"my big red car\"></div>");
}
#[cfg(feature = "ssr")]
#[test]
fn ssr_with_styles() {
use leptos::prelude::*;
@@ -119,6 +125,7 @@ fn ssr_with_styles() {
);
}
#[cfg(feature = "ssr")]
#[test]
fn ssr_option() {
use leptos::prelude::*;

View File

@@ -10,18 +10,18 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
config = { version = "0.14.0", default-features = false, features = [
config = { version = "0.14.1", default-features = false, features = [
"toml",
"convert-case",
] }
regex = "1.10"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
typed-builder = "0.19.1"
regex = "1.11"
serde = { version = "1.0", features = ["derive", "rc"] }
thiserror = "2.0"
typed-builder = "0.20.0"
[dev-dependencies]
tokio = { version = "1.39", features = ["rt", "macros"] }
tempfile = "3.12"
tokio = { version = "1.41", features = ["rt", "macros"] }
tempfile = "3.13"
temp-env = { version = "0.3.6", features = ["async_closure"] }
[package.metadata.docs.rs]

View File

@@ -5,7 +5,9 @@ pub mod errors;
use crate::errors::LeptosConfigError;
use config::{Case, Config, File, FileFormat};
use regex::Regex;
use std::{env::VarError, fs, net::SocketAddr, path::Path, str::FromStr};
use std::{
env::VarError, fs, net::SocketAddr, path::Path, str::FromStr, sync::Arc,
};
use typed_builder::TypedBuilder;
/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
@@ -25,17 +27,17 @@ pub struct ConfFile {
pub struct LeptosOptions {
/// The name of the WASM and JS files generated by wasm-bindgen. Defaults to the crate name with underscores instead of dashes
#[builder(setter(into), default=default_output_name())]
pub output_name: String,
pub output_name: Arc<str>,
/// The path of the all the files generated by cargo-leptos. This defaults to '.' for convenience when integrating with other
/// tools.
#[builder(setter(into), default=default_site_root())]
#[serde(default = "default_site_root")]
pub site_root: String,
pub site_root: Arc<str>,
/// The path of the WASM and JS files generated by wasm-bindgen from the root of your app
/// By default, wasm-bindgen puts them in `pkg`.
#[builder(setter(into), default=default_site_pkg_dir())]
#[serde(default = "default_site_pkg_dir")]
pub site_pkg_dir: String,
pub site_pkg_dir: Arc<str>,
/// Used to configure the running environment of Leptos. Can be used to load dev constants and keys v prod, or change
/// things based on the deployment environment
/// I recommend passing in the result of `env::var("LEPTOS_ENV")`
@@ -66,11 +68,11 @@ pub struct LeptosOptions {
/// The path of a custom 404 Not Found page to display when statically serving content, defaults to `site_root/404.html`
#[builder(default = default_not_found_path())]
#[serde(default = "default_not_found_path")]
pub not_found_path: String,
pub not_found_path: Arc<str>,
/// The file name of the hash text file generated by cargo-leptos. Defaults to `hash.txt`.
#[builder(default = default_hash_file_name())]
#[serde(default = "default_hash_file_name")]
pub hash_file: String,
pub hash_file: Arc<str>,
/// If true, hashes will be generated for all files in the site_root and added to their file names.
/// Defaults to `true`.
#[builder(default = default_hash_files())]
@@ -96,9 +98,9 @@ impl LeptosOptions {
);
}
Ok(LeptosOptions {
output_name,
site_root: env_w_default("LEPTOS_SITE_ROOT", "target/site")?,
site_pkg_dir: env_w_default("LEPTOS_SITE_PKG_DIR", "pkg")?,
output_name: output_name.into(),
site_root: env_w_default("LEPTOS_SITE_ROOT", "target/site")?.into(),
site_pkg_dir: env_w_default("LEPTOS_SITE_PKG_DIR", "pkg")?.into(),
env: env_from_str(env_w_default("LEPTOS_ENV", "DEV")?.as_str())?,
site_addr: env_w_default("LEPTOS_SITE_ADDR", "127.0.0.1:3000")?
.parse()?,
@@ -113,8 +115,10 @@ impl LeptosOptions {
reload_ws_protocol: ws_from_str(
env_w_default("LEPTOS_RELOAD_WS_PROTOCOL", "ws")?.as_str(),
)?,
not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?,
hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?,
not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?
.into(),
hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?
.into(),
hash_files: env_w_default("LEPTOS_HASH_FILES", "false")?.parse()?,
})
}
@@ -126,16 +130,16 @@ impl Default for LeptosOptions {
}
}
fn default_output_name() -> String {
env!("CARGO_CRATE_NAME").replace('-', "_")
fn default_output_name() -> Arc<str> {
env!("CARGO_CRATE_NAME").replace('-', "_").into()
}
fn default_site_root() -> String {
".".to_string()
fn default_site_root() -> Arc<str> {
".".into()
}
fn default_site_pkg_dir() -> String {
"pkg".to_string()
fn default_site_pkg_dir() -> Arc<str> {
"pkg".into()
}
fn default_env() -> Env {
@@ -150,12 +154,12 @@ fn default_reload_port() -> u32 {
3001
}
fn default_not_found_path() -> String {
"/404".to_string()
fn default_not_found_path() -> Arc<str> {
"/404".into()
}
fn default_hash_file_name() -> String {
"hash.txt".to_string()
fn default_hash_file_name() -> Arc<str> {
"hash.txt".into()
}
fn default_hash_files() -> bool {

View File

@@ -76,9 +76,9 @@ fn try_from_env_test() {
|| LeptosOptions::try_from_env().unwrap(),
);
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app_test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()

View File

@@ -50,9 +50,9 @@ async fn get_configuration_from_file_ok() {
)
.await;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -106,9 +106,9 @@ async fn get_config_from_file_ok() {
)
.await;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -151,9 +151,9 @@ fn get_config_from_str_content() {
|| get_config_from_str(CARGO_TOML_CONTENT_OK).unwrap(),
);
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -178,9 +178,9 @@ async fn get_config_from_env() {
)
.await;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -202,8 +202,8 @@ async fn get_config_from_env() {
)
.await;
assert_eq!(config.site_root, "target/site");
assert_eq!(config.site_pkg_dir, "pkg");
assert_eq!(config.site_root.as_ref(), "target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("127.0.0.1:3000").unwrap()
@@ -215,10 +215,10 @@ async fn get_config_from_env() {
#[test]
fn leptos_options_builder_default() {
let conf = LeptosOptions::builder().output_name("app-test").build();
assert_eq!(conf.output_name, "app-test");
assert_eq!(conf.output_name.as_ref(), "app-test");
assert!(matches!(conf.env, Env::DEV));
assert_eq!(conf.site_pkg_dir, "pkg");
assert_eq!(conf.site_root, ".");
assert_eq!(conf.site_pkg_dir.as_ref(), "pkg");
assert_eq!(conf.site_root.as_ref(), ".");
assert_eq!(
conf.site_addr,
SocketAddr::from_str("127.0.0.1:3000").unwrap()
@@ -242,9 +242,9 @@ fn environment_variable_override() {
|| get_config_from_str(CARGO_TOML_CONTENT_OK).unwrap(),
);
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -265,9 +265,9 @@ fn environment_variable_override() {
|| get_config_from_str(CARGO_TOML_CONTENT_OK).unwrap(),
);
assert_eq!(config.output_name, "app-test2");
assert_eq!(config.site_root, "my_target/site2");
assert_eq!(config.site_pkg_dir, "my_pkg2");
assert_eq!(config.output_name.as_ref(), "app-test2");
assert_eq!(config.site_root.as_ref(), "my_target/site2");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg2");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:82").unwrap()

View File

@@ -12,10 +12,10 @@ edition.workspace = true
tachys = { workspace = true }
reactive_graph = { workspace = true }
or_poisoned = { workspace = true }
js-sys = "0.3.69"
js-sys = "0.3.72"
send_wrapper = "0.6.0"
tracing = { version = "0.1.40", optional = true }
wasm-bindgen = "0.2.93"
wasm-bindgen = "0.2.95"
serde_json = { version = "1.0", optional = true }
serde = { version = "1.0", optional = true }
@@ -23,7 +23,7 @@ serde = { version = "1.0", optional = true }
leptos = { path = "../leptos" }
[dependencies.web-sys]
version = "0.3.70"
version = "0.3.72"
features = ["Location"]
[features]

View File

@@ -4,11 +4,11 @@ version = "0.1.0"
edition = "2021"
[dependencies]
console_error_panic_hook = "0.1.0"
console_error_panic_hook = "0.1.7"
gloo = { version = "0.11.0", features = ["futures"] }
leptos = { path = "../../../leptos", features = ["nightly", "csr", "tracing"] }
tracing = "0.1.0"
tracing-subscriber = "0.3.0"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
tracing-subscriber-wasm = "0.1.0"
[workspace]

View File

@@ -396,8 +396,7 @@ impl IntervalHandle {
}
}
/// Repeatedly calls the given function, with a delay of the given duration between calls,
/// returning a cancelable handle.
/// Repeatedly calls the given function, with a delay of the given duration between calls.
/// See [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval).
#[cfg_attr(
feature = "tracing",

View File

@@ -8,5 +8,4 @@ pub mod helpers;
pub mod macro_helpers;
/// Utilities for simple isomorphic logging to the console or terminal.
#[macro_use]
pub mod logging;

View File

@@ -25,4 +25,4 @@ proc-macro2 = { version = "1.0", features = ["span-locations", "nightly"] }
parking_lot = "0.12.3"
walkdir = "2.5"
camino = "1.1"
indexmap = "2.3"
indexmap = "2.6"

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_macro"
version = "0.7.0-gamma"
version = "0.7.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
@@ -13,11 +13,11 @@ edition.workspace = true
proc-macro = true
[dependencies]
attribute-derive = { version = "0.9.2", features = ["syn-full"] }
attribute-derive = { version = "0.10.2", features = ["syn-full"] }
cfg-if = "1.0"
html-escape = "0.2.13"
itertools = "0.13.0"
prettyplease = "0.2.20"
prettyplease = "0.2.25"
proc-macro-error2 = { version = "2.0", default-features = false }
proc-macro2 = "1.0"
quote = "1.0"
@@ -26,16 +26,16 @@ rstml = "0.12.0"
leptos_hot_reload = { workspace = true }
server_fn_macro = { workspace = true }
convert_case = "0.6.0"
uuid = { version = "1.10", features = ["v4"] }
uuid = { version = "1.11", features = ["v4"] }
tracing = { version = "0.1.40", optional = true }
[dev-dependencies]
log = "0.4.22"
typed-builder = "0.19.1"
typed-builder = "0.20.0"
trybuild = "1.0"
leptos = { path = "../leptos" }
server_fn = { path = "../server_fn", features = ["cbor"] }
insta = "1.39"
insta = "1.41"
serde = "1.0"
[features]
@@ -48,10 +48,39 @@ experimental-islands = []
trace-component-props = []
actix = ["server_fn_macro/actix"]
axum = ["server_fn_macro/axum"]
generic = ["server_fn_macro/generic"]
[package.metadata.cargo-all-features]
denylist = ["nightly", "tracing", "trace-component-props"]
skip_feature_sets = [["csr", "hydrate"], ["hydrate", "csr"], ["hydrate", "ssr"]]
skip_feature_sets = [
[
"csr",
"hydrate",
],
[
"hydrate",
"csr",
],
[
"hydrate",
"ssr",
],
[
"actix",
"axum",
],
[
"actix",
"generic",
],
[
"generic",
"axum",
],
]
[package.metadata.docs.rs]
rustdoc-args = ["--generate-link-to-definition"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(erase_components)'] }

View File

@@ -18,6 +18,7 @@ use syn::{
};
pub struct Model {
is_transparent: bool,
island: Option<String>,
docs: Docs,
unknown_attrs: UnknownAttrs,
@@ -62,6 +63,7 @@ impl Parse for Model {
});
Ok(Self {
is_transparent: false,
island: None,
docs,
unknown_attrs,
@@ -102,6 +104,7 @@ pub fn convert_from_snake_case(name: &Ident) -> Ident {
impl ToTokens for Model {
fn to_tokens(&self, tokens: &mut TokenStream) {
let Self {
is_transparent,
island,
docs,
unknown_attrs,
@@ -116,20 +119,22 @@ impl ToTokens for Model {
let no_props = props.is_empty();
// check for components that end ;
let ends_semi =
body.block.stmts.iter().last().and_then(|stmt| match stmt {
Stmt::Item(Item::Macro(mac)) => mac.semi_token.as_ref(),
_ => None,
});
if let Some(semi) = ends_semi {
proc_macro_error2::emit_error!(
semi.span(),
"A component that ends with a `view!` macro followed by a \
semicolon will return (), an empty view. This is usually an \
accident, not intentional, so we prevent it. If youd like \
to return (), you can do it it explicitly by returning () as \
the last item from the component."
);
if !is_transparent {
let ends_semi =
body.block.stmts.iter().last().and_then(|stmt| match stmt {
Stmt::Item(Item::Macro(mac)) => mac.semi_token.as_ref(),
_ => None,
});
if let Some(semi) = ends_semi {
proc_macro_error2::emit_error!(
semi.span(),
"A component that ends with a `view!` macro followed by a \
semicolon will return (), an empty view. This is usually \
an accident, not intentional, so we prevent it. If youd \
like to return (), you can do it it explicitly by \
returning () as the last item from the component."
);
}
}
//body.sig.ident = format_ident!("__{}", body.sig.ident);
@@ -265,14 +270,30 @@ impl ToTokens for Model {
}
};
let component = quote! {
::leptos::prelude::untrack(
move || {
#tracing_guard_expr
#tracing_props_expr
#body_expr
}
)
let component = if *is_transparent {
body_expr
} else if cfg!(erase_components) {
quote! {
::leptos::prelude::IntoAny::into_any(
::leptos::prelude::untrack(
move || {
#tracing_guard_expr
#tracing_props_expr
#body_expr
}
)
)
}
} else {
quote! {
::leptos::prelude::untrack(
move || {
#tracing_guard_expr
#tracing_props_expr
#body_expr
}
)
}
};
// add island wrapper if island
@@ -520,6 +541,13 @@ impl ToTokens for Model {
}
impl Model {
#[allow(clippy::wrong_self_convention)]
pub fn is_transparent(mut self, is_transparent: bool) -> Self {
self.is_transparent = is_transparent;
self
}
#[allow(clippy::wrong_self_convention)]
pub fn with_island(mut self, island: Option<String>) -> Self {
self.island = island;

View File

@@ -535,11 +535,24 @@ pub fn include_view(tokens: TokenStream) -> TokenStream {
/// ```
#[proc_macro_error2::proc_macro_error]
#[proc_macro_attribute]
pub fn component(
_args: proc_macro::TokenStream,
s: TokenStream,
) -> TokenStream {
component_macro(s, None)
pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
let is_transparent = if !args.is_empty() {
let transparent = parse_macro_input!(args as syn::Ident);
if transparent != "transparent" {
abort!(
transparent,
"only `transparent` is supported";
help = "try `#[component(transparent)]` or `#[component]`"
);
}
true
} else {
false
};
component_macro(s, is_transparent, None)
}
/// Defines a component as an interactive island when you are using the
@@ -615,17 +628,37 @@ pub fn component(
/// ```
#[proc_macro_error2::proc_macro_error]
#[proc_macro_attribute]
pub fn island(_args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
pub fn island(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
let is_transparent = if !args.is_empty() {
let transparent = parse_macro_input!(args as syn::Ident);
if transparent != "transparent" {
abort!(
transparent,
"only `transparent` is supported";
help = "try `#[component(transparent)]` or `#[component]`"
);
}
true
} else {
false
};
let island_src = s.to_string();
component_macro(s, Some(island_src))
component_macro(s, is_transparent, Some(island_src))
}
fn component_macro(s: TokenStream, island: Option<String>) -> TokenStream {
fn component_macro(
s: TokenStream,
is_transparent: bool,
island: Option<String>,
) -> TokenStream {
let mut dummy = syn::parse::<DummyModel>(s.clone());
let parse_result = syn::parse::<component::Model>(s);
if let (Ok(ref mut unexpanded), Ok(model)) = (&mut dummy, parse_result) {
let expanded = model.with_island(island).into_token_stream();
let expanded = model.is_transparent(is_transparent).with_island(island).into_token_stream();
if !matches!(unexpanded.vis, Visibility::Public(_)) {
unexpanded.vis = Visibility::Public(Pub {
span: unexpanded.vis.span(),
@@ -893,7 +926,7 @@ pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// Derives a trait that parses a map of string keys and values into a typed
/// data structure, e.g., for route params.
#[proc_macro_derive(Params, attributes(params))]
#[proc_macro_derive(Params)]
pub fn params_derive(
input: proc_macro::TokenStream,
) -> proc_macro::TokenStream {

View File

@@ -32,7 +32,7 @@ pub fn params_impl(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
let gen = quote! {
impl Params for #name {
fn from_map(map: &::leptos_router::params::ParamsMap) -> Result<Self, ::leptos_router::params::ParamsError> {
fn from_map(map: &::leptos_router::params::ParamsMap) -> ::core::result::Result<Self, ::leptos_router::params::ParamsError> {
Ok(Self {
#(#fields,)*
})

View File

@@ -1,4 +1,6 @@
use super::{fragment_to_tokens, TagType};
use super::{
fragment_to_tokens, utils::is_nostrip_optional_and_update_key, TagType,
};
use crate::view::{attribute_absolute, utils::filter_prefixed_attrs};
use proc_macro2::{Ident, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
@@ -44,9 +46,10 @@ pub(crate) fn component_to_tokens(
})
.unwrap_or_else(|| node.attributes().len());
let attrs = node
.attributes()
.iter()
// Initially using uncloned mutable reference, as the node.key might be mutated during prop extraction (for nostrip:)
let mut attrs = node
.attributes_mut()
.iter_mut()
.filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
Some(node)
@@ -54,39 +57,46 @@ pub(crate) fn component_to_tokens(
None
}
})
.cloned()
.collect::<Vec<_>>();
let props = attrs
.iter()
.enumerate()
.filter(|(idx, attr)| {
idx < &spread_marker && {
let attr_key = attr.key.to_string();
!is_attr_let(&attr.key)
&& !attr_key.starts_with("clone:")
&& !attr_key.starts_with("class:")
&& !attr_key.starts_with("style:")
&& !attr_key.starts_with("attr:")
&& !attr_key.starts_with("prop:")
&& !attr_key.starts_with("on:")
&& !attr_key.starts_with("use:")
}
})
.map(|(_, attr)| {
let name = &attr.key;
let mut required_props = vec![];
let mut optional_props = vec![];
for (_, attr) in attrs.iter_mut().enumerate().filter(|(idx, attr)| {
idx < &spread_marker && {
let attr_key = attr.key.to_string();
!is_attr_let(&attr.key)
&& !attr_key.starts_with("clone:")
&& !attr_key.starts_with("class:")
&& !attr_key.starts_with("style:")
&& !attr_key.starts_with("attr:")
&& !attr_key.starts_with("prop:")
&& !attr_key.starts_with("on:")
&& !attr_key.starts_with("use:")
}
}) {
let optional = is_nostrip_optional_and_update_key(&mut attr.key);
let name = &attr.key;
let value = attr
.value()
.map(|v| {
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
let value = attr
.value()
.map(|v| {
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
quote! {
if optional {
optional_props.push(quote! {
props.#name = { #value }.map(Into::into);
})
} else {
required_props.push(quote! {
.#name(#[allow(unused_braces)] { #value })
}
});
})
}
}
// Drop the mutable reference to the node, go to an owned clone:
let attrs = attrs.into_iter().map(|a| a.clone()).collect::<Vec<_>>();
let items_to_bind = attrs
.iter()
@@ -264,14 +274,20 @@ pub(crate) fn component_to_tokens(
let mut component = quote! {
{
#[allow(unreachable_code)]
#[allow(unused_mut)]
#[allow(clippy::let_and_return)]
::leptos::component::component_view(
#[allow(clippy::needless_borrows_for_generic_args)]
&#name,
::leptos::component::component_props_builder(&#name #generics)
#(#props)*
#(#slots)*
#children
.build()
{
let mut props = ::leptos::component::component_props_builder(&#name #generics)
#(#required_props)*
#(#slots)*
#children
.build();
#(#optional_props)*
props
}
)
#spreads
}

View File

@@ -23,8 +23,11 @@ use std::{
collections::{HashMap, HashSet, VecDeque},
};
use syn::{
spanned::Spanned, Expr, Expr::Tuple, ExprLit, ExprRange, Lit, LitStr,
RangeLimits, Stmt,
punctuated::Pair::{End, Punctuated},
spanned::Spanned,
Expr,
Expr::Tuple,
ExprArray, ExprLit, ExprRange, Lit, LitStr, RangeLimits, Stmt,
};
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -179,7 +182,7 @@ fn is_inert_element(orig_node: &Node<impl CustomNode>) -> bool {
}
enum Item<'a, T> {
Node(&'a Node<T>),
Node(&'a Node<T>, bool),
ClosingTag(String),
}
@@ -290,10 +293,11 @@ impl<'a> InertElementBuilder<'a> {
fn inert_element_to_tokens(
node: &Node<impl CustomNode>,
escape_text: bool,
global_class: Option<&TokenTree>,
) -> Option<TokenStream> {
let mut html = InertElementBuilder::new(global_class);
let mut nodes = VecDeque::from([Item::Node(node)]);
let mut nodes = VecDeque::from([Item::Node(node, escape_text)]);
while let Some(current) = nodes.pop_front() {
match current {
@@ -303,21 +307,32 @@ fn inert_element_to_tokens(
html.push_str(&tag);
html.push('>');
}
Item::Node(current) => {
Item::Node(current, escape) => {
match current {
Node::RawText(raw) => {
let text = raw.to_string_best();
let text = html_escape::encode_text(&text);
let text = if escape {
html_escape::encode_text(&text)
} else {
text.into()
};
html.push_str(&text);
}
Node::Text(text) => {
let text = text.value_string();
let text = html_escape::encode_text(&text);
let text = if escape {
html_escape::encode_text(&text)
} else {
text.into()
};
html.push_str(&text);
}
Node::Element(node) => {
let self_closing = is_self_closing(node);
let el_name = node.name().to_string();
let escape = el_name != "script"
&& el_name != "style"
&& el_name != "textarea";
// opening tag
html.push('<');
@@ -326,9 +341,12 @@ fn inert_element_to_tokens(
for attr in node.attributes() {
if let NodeAttribute::Attribute(attr) = attr {
let attr_name = attr.key.to_string();
// trim r# from raw identifiers like r#as
let attr_name =
attr_name.trim_start_matches("r#");
if attr_name != "class" {
html.push(' ');
html.push_str(&attr_name);
html.push_str(attr_name);
}
if let Some(value) =
@@ -361,7 +379,7 @@ fn inert_element_to_tokens(
nodes.push_front(Item::ClosingTag(el_name));
let children = node.children.iter().rev();
for child in children {
nodes.push_front(Item::Node(child));
nodes.push_front(Item::Node(child, escape));
}
}
}
@@ -545,7 +563,9 @@ fn node_to_tokens(
view_marker,
disable_inert_html,
),
Node::Block(block) => Some(quote! { #block }),
Node::Block(block) => {
Some(quote! { ::leptos::prelude::IntoRender::into_render(#block) })
}
Node::Text(text) => Some(text_to_tokens(&text.value)),
Node::RawText(raw) => {
let text = raw.to_string_best();
@@ -554,7 +574,11 @@ fn node_to_tokens(
}
Node::Element(el_node) => {
if !top_level && is_inert {
inert_element_to_tokens(node, global_class)
let el_name = el_node.name().to_string();
let escape = el_name != "script"
&& el_name != "style"
&& el_name != "textarea";
inert_element_to_tokens(node, escape, global_class)
} else {
element_to_tokens(
el_node,
@@ -629,6 +653,9 @@ pub(crate) fn element_to_tokens(
_ => None,
};
match (key_a.as_deref(), key_b.as_deref()) {
(Some("class"), Some("class")) | (Some("style"), Some("style")) => {
Ordering::Equal
}
(Some("class"), _) | (Some("style"), _) => Ordering::Less,
(_, Some("class")) | (_, Some("style")) => Ordering::Greater,
_ => Ordering::Equal,
@@ -640,9 +667,18 @@ pub(crate) fn element_to_tokens(
for attr in node.attributes() {
if let NodeAttribute::Attribute(attr) = attr {
let mut name = attr.key.to_string();
if let Some(tuple_name) = tuple_name(&name, attr) {
name.push(':');
name.push_str(&tuple_name);
match tuple_name(&name, attr) {
TupleName::None => {}
TupleName::Str(tuple_name) => {
name.push(':');
name.push_str(&tuple_name);
}
TupleName::Array(names) => {
for tuple_name in names {
name.push(':');
name.push_str(&tuple_name);
}
}
}
if names.contains(&name) {
proc_macro_error2::emit_error!(
@@ -722,6 +758,11 @@ pub(crate) fn element_to_tokens(
quote! { ::leptos::tachys::html::element::#custom(#name) }
} else if is_svg_element(&tag) {
parent_type = TagType::Svg;
let name = if tag == "use" || tag == "use_" {
Ident::new_raw("use", name.span()).to_token_stream()
} else {
name.to_token_stream()
};
quote! { ::leptos::tachys::svg::#name() }
} else if is_math_ml_element(&tag) {
parent_type = TagType::Math;
@@ -875,7 +916,7 @@ fn attribute_to_tokens(
NodeName::Path(path) => path.path.get_ident(),
_ => unreachable!(),
};
let value = attribute_value(node);
let value = attribute_value(node, false);
quote! {
.#node_ref(#value)
}
@@ -925,13 +966,13 @@ fn attribute_to_tokens(
// we don't provide statically-checked methods for SVG attributes
|| (tag_type == TagType::Svg && name != "inner_html")
{
let value = attribute_value(node);
let value = attribute_value(node, true);
quote! {
.attr(#name, #value)
}
} else {
let key = attribute_name(&node.key);
let value = attribute_value(node);
let value = attribute_value(node, true);
// special case of global_class and class attribute
if &node.key.to_string() == "class"
@@ -960,19 +1001,20 @@ pub(crate) fn attribute_absolute(
) -> Option<TokenStream> {
let key = node.key.to_string();
let contains_dash = key.contains('-');
let attr_aira = key.starts_with("attr:aria-");
let attr_colon = key.starts_with("attr:");
// anything that follows the x:y pattern
match &node.key {
NodeName::Punctuated(parts) if !contains_dash || attr_aira => {
NodeName::Punctuated(parts) if !contains_dash || attr_colon => {
if parts.len() >= 2 {
let id = &parts[0];
match id {
NodeNameFragment::Ident(id) => {
let value = attribute_value(node);
// ignore `let:` and `clone:`
if id == "let" || id == "clone" {
None
} else if id == "attr" {
let value = attribute_value(node, true);
let multipart = parts.len() > 2;
let key = &parts[1];
let key_name = key.to_string();
if key_name == "class" || key_name == "style" {
@@ -980,6 +1022,7 @@ pub(crate) fn attribute_absolute(
quote! { ::leptos::tachys::html::#key::#key(#value) },
)
} else if key_name == "aria" {
let value = attribute_value(node, true);
let mut parts_iter = parts.iter();
parts_iter.next();
let fn_name = parts_iter.map(|p| p.to_string()).collect::<Vec<String>>().join("_");
@@ -987,6 +1030,15 @@ pub(crate) fn attribute_absolute(
Some(
quote! { ::leptos::tachys::html::attribute::#key(#value) },
)
} else if multipart {
// e.g., attr:data-foo="bar"
let key_name = parts.pairs().skip(1).map(|p| match p {
Punctuated(n, p) => format!("{n}{p}"),
End(n) => n.to_string(),
}).collect::<String>();
Some(
quote! { ::leptos::tachys::html::attribute::custom::custom_attribute(#key_name, #value) },
)
} else {
Some(
quote! { ::leptos::tachys::html::attribute::#key(#value) },
@@ -1008,6 +1060,7 @@ pub(crate) fn attribute_absolute(
},
)
} else if id == "style" || id == "class" {
let value = attribute_value(node, false);
let key = &node.key.to_string();
let key = key
.replacen("style:", "", 1)
@@ -1016,6 +1069,7 @@ pub(crate) fn attribute_absolute(
quote! { ::leptos::tachys::html::#id::#id((#key, #value)) },
)
} else if id == "prop" {
let value = attribute_value(node, false);
let key = &node.key.to_string();
let key = key.replacen("prop:", "", 1);
Some(
@@ -1072,7 +1126,7 @@ pub(crate) fn two_way_binding_to_tokens(
name: &str,
node: &KeyedAttribute,
) -> TokenStream {
let value = attribute_value(node);
let value = attribute_value(node, false);
let ident =
format_ident!("{}", name.to_case(UpperCamel), span = node.key.span());
@@ -1097,7 +1151,7 @@ pub(crate) fn event_type_and_handler(
name: &str,
node: &KeyedAttribute,
) -> (TokenStream, TokenStream, TokenStream) {
let handler = attribute_value(node);
let handler = attribute_value(node, false);
let (event_type, is_custom, is_force_undelegated, is_targeted) =
parse_event_name(name);
@@ -1154,7 +1208,33 @@ fn class_to_tokens(
class: TokenStream,
class_name: Option<&str>,
) -> TokenStream {
let value = attribute_value(node);
// case of class=(["foo", "bar"], /* something */)
// just expands to multiple uses of class:
if let Some(Tuple(tuple)) = node.value() {
if tuple.elems.len() == 2 {
let name = &tuple.elems[0];
let value = &tuple.elems[1];
if let Expr::Array(ExprArray { elems, .. }) = name {
return elems
.iter()
.map(|elem| match elem {
Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) => quote! {
.#class((#s, #value))
},
_ => proc_macro_error2::abort!(
elem.span(),
"invalid name"
),
})
.collect();
}
}
}
// default case
let value = attribute_value(node, false);
if let Some(class_name) = class_name {
quote! {
.#class((#class_name, #value))
@@ -1171,7 +1251,7 @@ fn style_to_tokens(
style: TokenStream,
style_name: Option<&str>,
) -> TokenStream {
let value = attribute_value(node);
let value = attribute_value(node, false);
if let Some(style_name) = style_name {
quote! {
.#style((#style_name, #value))
@@ -1188,7 +1268,7 @@ fn prop_to_tokens(
prop: TokenStream,
key: &str,
) -> TokenStream {
let value = attribute_value(node);
let value = attribute_value(node, false);
quote! {
.#prop(#key, #value)
}
@@ -1345,7 +1425,10 @@ fn attribute_name(name: &NodeName) -> TokenStream {
}
}
fn attribute_value(attr: &KeyedAttribute) -> TokenStream {
fn attribute_value(
attr: &KeyedAttribute,
is_attribute_proper: bool,
) -> TokenStream {
match attr.possible_value.to_value() {
None => quote! { true },
Some(value) => match &value.value {
@@ -1360,14 +1443,26 @@ fn attribute_value(attr: &KeyedAttribute) -> TokenStream {
}
}
quote! {
{#expr}
if matches!(expr, Expr::Lit(_)) || !is_attribute_proper {
quote! {
#expr
}
} else {
quote! {
::leptos::prelude::IntoAttributeValue::into_attribute_value(#expr)
}
}
}
// any value in braces: expand as-is to give proper r-a support
KVAttributeValue::InvalidBraced(block) => {
quote! {
#block
if is_attribute_proper {
quote! {
::leptos::prelude::IntoAttributeValue::into_attribute_value(#block)
}
} else {
quote! {
#block
}
}
}
},
@@ -1573,7 +1668,7 @@ pub(crate) fn directive_call_from_attribute_node(
quote! { .directive(#handler, #[allow(clippy::useless_conversion)] #param) }
}
fn tuple_name(name: &str, node: &KeyedAttribute) -> Option<String> {
fn tuple_name(name: &str, node: &KeyedAttribute) -> TupleName {
if name == "style" || name == "class" {
if let Some(Tuple(tuple)) = node.value() {
{
@@ -1583,12 +1678,37 @@ fn tuple_name(name: &str, node: &KeyedAttribute) -> Option<String> {
lit: Lit::Str(s), ..
}) = style_name
{
return Some(s.value());
return TupleName::Str(s.value());
} else if let Expr::Array(ExprArray { elems, .. }) =
style_name
{
return TupleName::Array(
elems
.iter()
.filter_map(|elem| match elem {
Expr::Lit(ExprLit {
lit: Lit::Str(s),
..
}) => Some(s.value()),
_ => proc_macro_error2::abort!(
elem.span(),
"invalid name"
),
})
.collect(),
);
}
}
}
}
}
None
TupleName::None
}
#[derive(Debug)]
enum TupleName {
None,
Str(String),
Array(Vec<String>),
}

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