Compare commits

..

102 Commits

Author SHA1 Message Date
Greg Johnston
b2bea2e6b7 fix: support for websockets under default features 2025-03-12 21:06:52 -04:00
Greg Johnston
acbd6378a8 chore: bump version for 0.8 2025-03-12 20:29:46 -04:00
Greg Johnston
b7462aab10 fix: broken type inference for Action::new_unsync (closes #3328) (#3705) 2025-03-12 19:12:46 -04:00
Greg Johnston
7593540774 Merge remote-tracking branch 'origin' into leptos_0.8 2025-03-12 16:16:10 -04:00
Greg Johnston
ed915f8e06 Merge pull request #3656 from ealmloff/websockets
Add websocket support for server functions
2025-03-12 16:13:41 -04:00
Greg Johnston
f65d87d566 chore: allow dead code in example 2025-03-10 21:07:41 -04:00
Greg Johnston
5034539411 chore: update server_fns_axum example 2025-03-10 21:06:36 -04:00
Greg Johnston
bc48aa4228 chore: reexport Bytes to make it easier to implement Client 2025-03-10 21:06:26 -04:00
Greg Johnston
d2c81fe955 change: enable url and json by default on server_fn 2025-03-10 16:02:50 -04:00
Greg Johnston
28eb96831a chore: fix up doctests on server_fn crate 2025-03-10 15:57:59 -04:00
autofix-ci[bot]
599c87c88a [autofix.ci] apply automated fixes 2025-03-10 14:03:07 +00:00
Evan Almloff
3ca98279e1 forward server fn visibility 2025-03-10 08:52:32 -05:00
mahdi739
a730bffe13 fix: Ensure reactive functions passed to TextProp are kept reactive (closes: #3689) (#3690) 2025-03-09 19:40:28 -04:00
Evan Almloff
18570e970c Fix clippy 2025-03-07 17:19:24 -06:00
autofix-ci[bot]
787bf385d3 [autofix.ci] apply automated fixes 2025-03-07 23:18:19 +00:00
Evan Almloff
b6d2808671 Document the protocol trait and implementations 2025-03-07 17:12:49 -06:00
Evan Almloff
2e4d94b6c6 Add encoding suffix to the encoding types and revert renaming the post encodings 2025-03-07 17:12:49 -06:00
autofix-ci[bot]
66f9c8c999 [autofix.ci] apply automated fixes 2025-03-07 21:26:39 +00:00
Greg Johnston
352080d91a fix: don't use ws feature of Axum on JS-fetch/no-default platform 2025-03-07 16:19:11 -05:00
Greg Johnston
1e579614a5 chore: remove unused import 2025-03-07 15:55:28 -05:00
Evan Almloff
fee4bccb32 Create post type aliases for encodings 2025-03-07 08:40:08 -06:00
mahdi739
4ba9f67440 feat: implement IntoClass for store fields (#3670)
* Implement `IntoClass` for store fields

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-03-07 08:22:59 -05:00
autofix-ci[bot]
d84ab6d9bf [autofix.ci] apply automated fixes (attempt 2/3) 2025-03-05 16:23:48 +00:00
autofix-ci[bot]
f069d4478e [autofix.ci] apply automated fixes 2025-03-05 15:20:59 +00:00
Evan Almloff
65b5d55d62 deny missing docs 2025-03-05 09:01:22 -06:00
Evan Almloff
860ad7a221 return an error when the sever doesn't support spawning tasks 2025-03-05 09:00:26 -06:00
Evan Almloff
901e038aa0 update form integration 2025-03-05 08:53:54 -06:00
Evan Almloff
f49f0965bc remove default actix feature 2025-03-05 08:51:31 -06:00
Evan Almloff
bb62d08d3f implement actix integration 2025-03-05 08:47:06 -06:00
Greg Johnston
bfe04593fd Merge pull request #3658 from mahdi739/impl-several-into-traits-for-store-fields-0.8
Implement several into traits for store fields (0.8)
2025-03-04 08:54:02 -05:00
autofix-ci[bot]
5b484eaec4 [autofix.ci] apply automated fixes (attempt 3/3) 2025-03-03 22:02:22 +00:00
autofix-ci[bot]
5149ad54db [autofix.ci] apply automated fixes (attempt 2/3) 2025-03-03 21:36:44 +00:00
autofix-ci[bot]
4d9ec54ad1 [autofix.ci] apply automated fixes 2025-03-03 21:22:46 +00:00
Evan Almloff
a1cd7ae9a1 implement reqwest websocket integration 2025-03-03 14:58:37 -06:00
Greg Johnston
3dbb251853 fix: tweak bounds on For for backwards-compat (#3663) 2025-03-01 11:50:09 -05:00
zakstucke
98e00fcb3b Erased mode in CI (#3640)
* Erased mode in CI

* Trigger CI

* Rename dev_mode erased_mode plus add to more matrices

* nested routes in separate component fix

* Fix lint

* Small fixes

* Fixes

* proc-macro rustflags cross-compilation workaround with internal erasure feature for leptos_macro

* Re-trigger CI

* fix unrelated doc CI and remove unneeded IntoAttribute trait

* Fix StaticVec rebuild() fn

* Conflict fixes

* Maybe fix

* Bump example toolchain
2025-03-01 07:43:13 -08:00
Greg Johnston
cdee2a9476 feat: "islands router" for client-side navigations when in islands mode (#3502) 2025-02-28 14:01:33 -05:00
autofix-ci[bot]
c97ab9a72c [autofix.ci] apply automated fixes 2025-02-28 15:27:34 +00:00
Evan Almloff
4fc8972f2b make the into websocket function async 2025-02-28 09:26:37 -06:00
Evan Almloff
b800c009c7 Integrate the websocket protocol with the server function macro 2025-02-27 16:31:06 -06:00
Greg Johnston
e7a73595de chore: remove unnecessary Debug bound 2025-02-27 17:00:00 -05:00
autofix-ci[bot]
a9a988e0e1 [autofix.ci] apply automated fixes 2025-02-27 14:46:56 +00:00
Mahdi
db10d961df Implement Render, RenderHtml, AttributeValue for store fields 2025-02-27 17:44:07 +03:30
Mahdi
fb608158cb Implement IntoSplitSignal for AtKeyed, AtIndex and DerefedField 2025-02-27 17:16:51 +03:30
Mahdi
1a472ebad1 Implement InnerHtmlValue for store fields 2025-02-27 17:09:34 +03:30
Mahdi
2d1b66a5c6 Implement IntoProperty for store fields 2025-02-27 17:00:33 +03:30
Mahdi
c524b0aefc rename style_store_field macro to style_reactive 2025-02-27 16:55:37 +03:30
Mahdi
e4c977911c Implement IntoStyle and IntoStyleValue for store fields 2025-02-27 16:41:53 +03:30
autofix-ci[bot]
f488d4b5b7 [autofix.ci] apply automated fixes 2025-02-26 19:58:25 +00:00
Evan Almloff
d4cfd0e2cb Move content type to a separate trait 2025-02-26 13:56:59 -06:00
autofix-ci[bot]
a4b0d3408c [autofix.ci] apply automated fixes 2025-02-26 15:44:01 +00:00
Evan Almloff
2037bf12cb Merge branch 'leptos_0.8' into websockets 2025-02-26 09:40:58 -06:00
Evan Almloff
2747a496fc fix formatting 2025-02-26 09:40:22 -06:00
Evan Almloff
c286812116 move the server request and response into a separate trait 2025-02-26 09:36:02 -06:00
Greg Johnston
1e0a9ef189 fix: avoid hydration issues with HashedStylesheet (closes #3633) (#3654) 2025-02-26 09:18:23 -05:00
Evan Almloff
7479010f84 unified http protocol for non-websocket sever functions 2025-02-25 16:20:33 -06:00
Evan Almloff
9b9983af79 expose encoding types 2025-02-25 08:36:04 -06:00
Greg Johnston
04e79a0dc4 chore: clean up fetch example a bit 2025-02-25 08:06:47 -05:00
Greg Johnston
f64951126e chore: merge issues 2025-02-25 07:42:39 -05:00
Greg Johnston
0a29071779 Merge remote-tracking branch 'origin' into leptos_0.8 2025-02-25 07:39:43 -05:00
Greg Johnston
efcb6f6d21 feat: support IntoSplitSignal for (Signal<T>, SignalSetter<T>) (closes #3634) (#3643) 2025-02-25 07:32:37 -05:00
Evan Almloff
6904eec207 remove into/from request/response bounds on server fn 2025-02-24 13:05:37 -06:00
Evan Almloff
7eb8ca702d switch most encodings into the new system 2025-02-24 12:00:02 -06:00
Evan Almloff
1a7b40b507 implement gloo websockets 2025-02-21 15:48:41 -06:00
Evan Almloff
73dd677843 switch to async fn for get implementation 2025-02-20 17:02:19 -06:00
Evan Almloff
131f414fdc move run client into the protocol trait 2025-02-20 17:01:51 -06:00
Evan Almloff
c3ed874d4d Create websocket protocol trait 2025-02-19 14:49:57 -06:00
benwis
f003e50446 Update throw_error to v0.3 2025-02-19 08:08:21 -08:00
Greg Johnston
ea29685c92 change: remove unused Result alias (#3543) 2025-02-19 07:22:20 -08:00
zakstucke
49e44a2ec2 Erased routing, codegen opts (#3623) 2025-02-18 12:39:30 -08:00
Greg Johnston
7157958822 feat: support Option<_> in style: (closes #3568) (#3618) 2025-02-15 16:47:21 -05:00
zakstucke
d37450d12f Internally erase html elements (#3614) 2025-02-15 09:58:43 -08:00
zakstucke
6ad300c592 Binary size wins (#3566)
* Random binary size opts

* [autofix.ci] apply automated fixes

* Stop autofixer removing an import

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-02-10 15:18:26 -08:00
zakstucke
b4e683d969 RenderHtml::into_owned (#3580) 2025-02-10 12:53:33 -08:00
Greg Johnston
c2289b23a7 fix: Actix stream error handling with 0.8 error types (#3574) 2025-02-10 08:32:44 -05:00
Greg Johnston
299acd25f3 implement AddAnyAttr for AnyView (#3562) 2025-02-09 20:46:41 -05:00
zakstucke
287fc47163 "Update axum paths to 0.8 syntax" (#3555)
* Update axum paths to 0.8 syntax

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-02-06 16:17:12 -08:00
zakstucke
8f74a6d8a0 AddAnyAttr static (#3553) 2025-02-06 14:00:25 -08:00
starmaker
597175a54b Fixing closing brace (#3539)
Most likely confglict merge artefact
2025-02-01 09:40:03 -08:00
Chris
ede25b9e3d fix: remove Default impl for LeptosOptions and ConfFile (#3522)
* fix: remove `Default` impl for `LeptosOptions` and `ConfFile`

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-02-01 09:41:44 -05:00
Greg Johnston
8f636e354a change: allow IntoFuture for Suspend::new() (closes #3509) (#3532) 2025-01-31 14:25:35 -05:00
Greg Johnston
7da64f22c4 Merge branch 'main' into leptos_0.8 2025-01-30 21:32:58 -05:00
Greg Johnston
0073ae7d8a chore: update version numbers preparing for 0.8.0-alpha 2025-01-30 21:29:54 -05:00
benwis
8465716a19 Fix formatting 2025-01-26 09:51:22 -08:00
zakstucke
0e24b2e63f AddAnyAttr working with erase_components (#3518)
* AddAnyAttr working with erase_components

* CI fixes
2025-01-26 09:51:22 -08:00
Danik Vitek
c64d205984 feat (either_of): Extent API; Implement other iterator methods; Update deps (#3478)
* Implement other iterator methods. Update deps

* Formatting

* Update Cargo.lock

* [autofix.ci] apply automated fixes

* Formatting

* Move `Either` declaration into the `tuples` macro

* Comment out non-MSRV-compliant methods

* [autofix.ci] apply automated fixes

* Formatting

* Implement mapping functions

* Fix clippy warnings

* Impl `Error`; Impl `From<Result<A, B>> for Either<B, A>`

* Fix `Error` impl

* Move `Error` impl under `#[cfg(not(feature="no_std"))] until MSRV >= 1.81

* [autofix.ci] apply automated fixes

* Make `From<Result>` compliant with `EitherOr`. Add `impl EitherOr for Either`

* fix: use fully-qualified name

* fix: `EitherOf` test

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-01-26 09:51:22 -08:00
Greg Johnston
f17cb98eb0 chore: update workspace dependency versions to latest (#3506) 2025-01-26 09:51:22 -08:00
Danik Vitek
30f3e82664 docs: Fix README.md & Add MSRV badge (#3480)
* Fix README.md

* Add MSRV badge

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-01-26 09:51:22 -08:00
starmaker
152d5a5c92 issue-3467 - bumping codee version to support rkyv 8 (#3504)
* issue-3467 - bumping codee version to support rkyv 8

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-01-26 09:51:22 -08:00
Sam Jude
669e1ba7fa hexagonal architecture (#3342)
Co-authored-by: Sam <@>
2025-01-26 09:51:22 -08:00
dcsturman
2ad6a086f9 Enhanced docs for reactive_stores (#3508)
Added docs on shadow traits, Option, Enum, Vec, and Box usage with Store.
2025-01-26 09:51:22 -08:00
Greg Johnston
32e58d6b66 fix: including node_ref after {..} on arbitrary components (#3503) 2025-01-26 09:51:22 -08:00
Greg Johnston
a107443104 chore(ci): add CI for leptos_0.8 branch (#3500) 2025-01-26 09:51:22 -08:00
Greg Johnston
c859b07901 feat: #[lazy] macros to support lazy loading and code splitting (#3477) 2025-01-26 09:51:22 -08:00
Greg Johnston
a9868bea2b chore: restore reactivity warning at top level of components (closes #3354) (#3499) 2025-01-26 09:51:22 -08:00
Greg Johnston
7183c2b993 fix: correctly handle ErrorBoundary through reactive views (closes #3487) (#3492) 2025-01-26 09:51:22 -08:00
Greg Johnston
7a03621db1 feat: implement unboxing support for recursive store nodes (closes #3491) (#3493) 2025-01-26 09:51:22 -08:00
Spencer Ferris
2b589fa61f feat: Add more options for generating server fn routes (#3438)
* feat: Allow disabling server fn hash and customizing the default prefix

Allow configuring the default prefix for server function API routes. This is useful to
override the default prefix (`/api`) for all server functions without needing to manually
specify via `#[server(prefix = "...")]` on every server function.

Also, allow disabling appending the server functions' hashes to the end of their API names.
This is useful when an app's client side needs a stable server API. For example, shipping
the CSR WASM binary in a Tauri app. Tauri app releases are dependent on each platform's
distribution method (e.g., the Apple App Store or the Google Play Store), which typically
are much slower than the frequency at which a website can be updated. In addition, it's
common for users to not have the latest app version installed. In these cases, the CSR WASM
app would need to be able to continue calling the backend server function API, so the API
path needs to be consistent and not have a hash appended.

* Mark public structs as `#[non_exhaustive]` and add doc comments

* Minor refactor to pull the fn hash logic out of the `path` statement

* feat: Use module path in prefix for server fn API route

Allow including the module path of the server function in the API route. This
is an alternative strategy to prevent duplicate server function API routes
(the default strategy is to add a hash to the end of the route). Each element
of the module path will be separated by a `/`. For example, a server function
with a fully qualified name of `parent:🧒:server_fn` would have an API
route of `/api/parent/child/server_fn` (possibly with a different prefix and
a hash suffix depending on the values of the other server fn configs).

* Fix `enable_hash` if statement

* Add missing import
2025-01-24 20:11:38 -08:00
Saber Haj Rabiee
35e6f17930 chore: upgrade axum to v0.8 (#3439) 2025-01-17 13:38:37 -05:00
Greg Johnston
d1513a4a0b feat(breaking): allow make PossibleRouteMatch dyn-safe (#3421) 2025-01-17 13:33:00 -05:00
Mario Carbajal
aa27b9e474 feat: impl Dispose for Callback types and add try_run to the Callable trait (#3371)
* impl Dispose for Callback types and add try_run to the Callable trait

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-01-17 13:32:04 -05:00
Ryo Hirayama
cfe925d58f feat: allow any type that implements FromServerFnError as a replacement of the ServerFnError in server_fn (#3274) 2025-01-17 13:30:12 -05:00
166 changed files with 11346 additions and 4516 deletions

View File

@@ -28,5 +28,6 @@ jobs:
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
erased_mode: ${{ matrix.erased_mode }}
cargo_make_task: "ci"
toolchain: stable

View File

@@ -25,5 +25,6 @@ jobs:
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
erased_mode: ${{ matrix.erased_mode }}
cargo_make_task: "ci"
toolchain: stable

View File

@@ -25,5 +25,6 @@ jobs:
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
erased_mode: ${{ matrix.erased_mode }}
cargo_make_task: "ci"
toolchain: nightly-2025-03-05

View File

@@ -50,5 +50,5 @@ jobs:
echo "matrix={\"directory\":${{ steps.changed-dirs.outputs.all_changed_files }}}" >> "$GITHUB_OUTPUT"
else
# Create matrix with one item to prevent an empty vector error
echo "matrix={\"directory\":[\"NO_CHANGE\"]}" >> "$GITHUB_OUTPUT"
echo "matrix={\"directory\":[\"NO_CHANGE\"], \"erased_mode\": [false, true]}" >> "$GITHUB_OUTPUT"
fi

View File

@@ -28,7 +28,7 @@ jobs:
sed 's/\/$//' |
jq -R -s -c 'split("\n")[:-1]')
echo "Example Directories: $examples"
echo "matrix={\"directory\":$examples}" >> "$GITHUB_OUTPUT"
echo "matrix={\"directory\":$examples, \"erased_mode\": [false, true]}" >> "$GITHUB_OUTPUT"
- name: Print Location Info
run: |
echo "Workspace: ${{ github.workspace }}"

View File

@@ -24,7 +24,7 @@ jobs:
sed "s|$(pwd)/||" |
jq -R -s -c 'split("\n")[:-1]')
echo "Leptos Directories: $crates"
echo "matrix={\"directory\":$crates}" >> "$GITHUB_OUTPUT"
echo "matrix={\"directory\":$crates, \"erased_mode\": [false, true]}" >> "$GITHUB_OUTPUT"
- name: Print Location Info
run: |
echo "Workspace: ${{ github.workspace }}"

View File

@@ -5,6 +5,9 @@ on:
directory:
required: true
type: string
erased_mode:
required: true
type: boolean
cargo_make_task:
required: true
type: string
@@ -15,9 +18,10 @@ env:
CARGO_TERM_COLOR: always
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
DEBIAN_FRONTEND: noninteractive
RUSTFLAGS: ${{ inputs.erased_mode && '--cfg erase_components' || '' }}
jobs:
test:
name: Run ${{ inputs.cargo_make_task }} (${{ inputs.toolchain }})
name: "Run ${{ inputs.cargo_make_task }} (${{ inputs.toolchain }}) (erased_mode: ${{ inputs.erased_mode }})"
runs-on: ubuntu-latest
steps:
- name: Free Disk Space

846
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,38 +40,38 @@ members = [
exclude = ["benchmarks", "examples", "projects"]
[workspace.package]
version = "0.7.7"
version = "0.8.0-alpha"
edition = "2021"
rust-version = "1.76"
[workspace.dependencies]
throw_error = { path = "./any_error/", version = "0.2.0" }
throw_error = { path = "./any_error/", version = "0.3.0" }
any_spawner = { path = "./any_spawner/", version = "0.2.0" }
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1" }
either_of = { path = "./either_of/", version = "0.1.5" }
hydration_context = { path = "./hydration_context", version = "0.2.0" }
hydration_context = { path = "./hydration_context", version = "0.3.0" }
itertools = "0.14.0"
leptos = { path = "./leptos", version = "0.7.7" }
leptos_config = { path = "./leptos_config", version = "0.7.7" }
leptos_dom = { path = "./leptos_dom", version = "0.7.7" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.7" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.7" }
leptos_macro = { path = "./leptos_macro", version = "0.7.7" }
leptos_router = { path = "./router", version = "0.7.7" }
leptos_router_macro = { path = "./router_macro", version = "0.7.7" }
leptos_server = { path = "./leptos_server", version = "0.7.7" }
leptos_meta = { path = "./meta", version = "0.7.7" }
leptos = { path = "./leptos", version = "0.8.0-alpha" }
leptos_config = { path = "./leptos_config", version = "0.8.0-alpha" }
leptos_dom = { path = "./leptos_dom", version = "0.8.0-alpha" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.8.0-alpha" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.8.0-alpha" }
leptos_macro = { path = "./leptos_macro", version = "0.8.0-alpha" }
leptos_router = { path = "./router", version = "0.8.0-alpha" }
leptos_router_macro = { path = "./router_macro", version = "0.8.0-alpha" }
leptos_server = { path = "./leptos_server", version = "0.8.0-alpha" }
leptos_meta = { path = "./meta", version = "0.8.0-alpha" }
next_tuple = { path = "./next_tuple", version = "0.1.0" }
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.7" }
reactive_stores = { path = "./reactive_stores", version = "0.1.7" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.7" }
reactive_graph = { path = "./reactive_graph", version = "0.2.0-alpha" }
reactive_stores = { path = "./reactive_stores", version = "0.2.0-alpha" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.2.0-alpha" }
serde_json = "1.0.0"
server_fn = { path = "./server_fn", version = "0.7.7" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.7" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.7" }
tachys = { path = "./tachys", version = "0.1.7" }
server_fn = { path = "./server_fn", version = "0.8.0-alpha" }
server_fn_macro = { path = "./server_fn_macro", version = "0.8.0-alpha" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.8.0-alpha" }
tachys = { path = "./tachys", version = "0.2.0-alpha" }
wasm-bindgen = { version = "0.2.100" }
[profile.release]

View File

@@ -1,6 +1,6 @@
[package]
name = "throw_error"
version = "0.2.0"
version = "0.3.0"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -17,11 +17,6 @@ use std::{
/* Wrapper Types */
/// This is a result type into which any error can be converted.
///
/// Results are stored as [`Error`].
pub type Result<T, E = Error> = core::result::Result<T, E>;
/// A generic wrapper for any error.
#[derive(Debug, Clone)]
#[repr(transparent)]

View File

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
console_error_panic_hook = "0.1.7"
console_log = "1.0"
gloo-utils = "0.2.0"
@@ -20,18 +20,27 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_router = { path = "../../router" }
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
tokio = { version = "1.39", features = [ "rt-multi-thread", "macros", "time" ], optional = true }
tokio = { version = "1.39", features = [
"rt-multi-thread",
"macros",
"time",
], optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
wasm-bindgen = "0.2.92"
web-sys = { version = "0.3.69", features = [ "AddEventListenerOptions", "Document", "Element", "Event", "EventListener", "EventTarget", "Performance", "Window" ], optional = true }
web-sys = { version = "0.3.69", features = [
"AddEventListenerOptions",
"Document",
"Element",
"Event",
"EventListener",
"EventTarget",
"Performance",
"Window",
], optional = true }
[features]
hydrate = [
"leptos/hydrate",
"dep:js-sys",
"dep:web-sys",
]
hydrate = ["leptos/hydrate", "dep:js-sys", "dep:web-sys"]
ssr = [
"dep:axum",
"dep:http-body-util",

View File

@@ -13,7 +13,7 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }

View File

@@ -45,7 +45,7 @@ async fn main() {
// build our application with a route
let app = Router::new()
.route("/special/:id", get(custom_handler))
.route("/special/{id}", get(custom_handler))
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())

View File

@@ -1,5 +1,4 @@
use leptos::prelude::*;
use leptos::tachys::html::style::style;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -16,7 +15,7 @@ pub enum CatError {
type CatCount = usize;
async fn fetch_cats(count: CatCount) -> Result<Vec<String>> {
async fn fetch_cats(count: CatCount) -> Result<Vec<String>, Error> {
if count > 0 {
gloo_timers::future::TimeoutFuture::new(1000).await;
// make the request
@@ -42,11 +41,7 @@ async fn fetch_cats(count: CatCount) -> Result<Vec<String>> {
pub fn fetch_example() -> impl IntoView {
let (cat_count, set_cat_count) = signal::<CatCount>(1);
// we use new_unsync here because the reqwasm request type isn't Send
// if we were doing SSR, then
// 1) we'd want to use a Resource, so the data would be serialized to the client
// 2) we'd need to make sure there was a thread-local spawner set up
let cats = AsyncDerived::new_unsync(move || fetch_cats(cat_count.get()));
let cats = LocalResource::new(move || fetch_cats(cat_count.get()));
let fallback = move |errors: ArcRwSignal<Errors>| {
let error_list = move || {
@@ -66,8 +61,6 @@ pub fn fetch_example() -> impl IntoView {
}
};
let spreadable = style(("background-color", "AliceBlue"));
view! {
<div>
<label>
@@ -82,7 +75,7 @@ pub fn fetch_example() -> impl IntoView {
/>
</label>
<Transition fallback=|| view! { <div>"Loading..."</div> } {..spreadable}>
<Transition fallback=|| view! { <div>"Loading..."</div> }>
<ErrorBoundary fallback>
<ul>
{move || Suspend::new(async move {
@@ -92,7 +85,7 @@ pub fn fetch_example() -> impl IntoView {
.map(|s| {
view! {
<li>
<img src=s.clone()/>
<img src=s.clone() />
</li>
}
})

View File

@@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] }
tracing = "0.1.40"
gloo-net = { version = "0.6.0", features = ["http"] }
reqwest = { version = "0.12.5", features = ["json"] }
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }

View File

@@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] }
tracing = "0.1.40"
gloo-net = { version = "0.6.0", features = ["http"] }
reqwest = { version = "0.12.5", features = ["json"] }
axum = { version = "0.7.5", optional = true, features = ["http2"] }
axum = { version = "0.8.1", optional = true, features = ["http2"] }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = [
"fs",

View File

@@ -23,7 +23,7 @@ serde = { version = "1.0", features = ["derive"] }
tracing = "0.1.40"
gloo-net = { version = "0.6.0", features = ["http"] }
reqwest = { version = "0.12.5", features = ["json"] }
axum = { version = "0.7.5", default-features = false, optional = true }
axum = { version = "0.8.1", default-features = false, optional = true }
tower = { version = "0.4.13", optional = true }
http = { version = "1.1", optional = true }
web-sys = { version = "0.3.70", features = [

View File

@@ -10,15 +10,12 @@ crate-type = ["cdylib", "rlib"]
console_error_panic_hook = "0.1.7"
futures = "0.3.30"
http = "1.1"
leptos = { path = "../../leptos", features = [
"tracing",
"islands",
] }
leptos = { path = "../../leptos", features = ["tracing", "islands"] }
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }

View File

@@ -10,22 +10,20 @@ crate-type = ["cdylib", "rlib"]
console_error_panic_hook = "0.1.7"
futures = "0.3.30"
http = "1.1"
leptos = { path = "../../leptos", features = [
"tracing",
"islands",
] }
leptos = { path = "../../leptos", features = ["tracing", "islands"] }
leptos_router = { path = "../../router" }
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
leptos_axum = { path = "../../integrations/axum", features = [
"dont-use-islands-router",
"islands-router",
], optional = true }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }
wasm-bindgen = "0.2.93"
wasm-bindgen = "0.2.100"
serde_json = "1.0.133"
[features]
hydrate = ["leptos/hydrate"]
@@ -58,11 +56,11 @@ site-root = "target/site"
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
style-file = "style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
site-addr = "127.0.0.1:3009"
# The port to use for automatic reload monitoring
reload-port = 3001
# The browserlist query used for optimizing the CSS.

File diff suppressed because it is too large Load Diff

View File

@@ -1,140 +0,0 @@
window.addEventListener("click", async (ev) => {
// confirm that this is an <a> that meets our requirements
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.altKey ||
ev.ctrlKey ||
ev.shiftKey
)
return;
/** @type HTMLAnchorElement | undefined;*/
const a = ev
.composedPath()
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
if (!a) return;
const svg = a.namespaceURI === "http://www.w3.org/2000/svg";
const href = svg ? a.href.baseVal : a.href;
const target = svg ? a.target.baseVal : a.target;
if (target || (!href && !a.hasAttribute("state"))) return;
const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel && rel.includes("external"))) return;
const url = svg ? new URL(href, document.baseURI) : new URL(href);
if (
url.origin !== window.location.origin // ||
// TODO base
//(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase()))
)
return;
ev.preventDefault();
// fetch the new page
const resp = await fetch(url);
const htmlString = await resp.text();
// Use DOMParser to parse the HTML string
const parser = new DOMParser();
// TODO parse from the request stream instead?
const doc = parser.parseFromString(htmlString, 'text/html');
// The 'doc' variable now contains the parsed DOM
const transition = async () => {
const oldDocWalker = document.createTreeWalker(document);
const newDocWalker = doc.createTreeWalker(doc);
let oldNode = oldDocWalker.currentNode;
let newNode = newDocWalker.currentNode;
while(oldDocWalker.nextNode() && newDocWalker.nextNode()) {
oldNode = oldDocWalker.currentNode;
newNode = newDocWalker.currentNode;
// if the nodes are different, we need to replace the old with the new
// because of the typed view tree, this should never actually happen
if (oldNode.nodeType !== newNode.nodeType) {
oldNode.replaceWith(newNode);
}
// if it's a text node, just update the text with the new text
else if (oldNode.nodeType === Node.TEXT_NODE) {
oldNode.textContent = newNode.textContent;
}
// if it's an element, replace if it's a different tag, or update attributes
else if (oldNode.nodeType === Node.ELEMENT_NODE) {
/** @type Element */
const oldEl = oldNode;
/** @type Element */
const newEl = newNode;
if (oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
}
else {
for(const attr of newEl.attributes) {
oldEl.setAttribute(attr.name, attr.value);
}
}
}
// we use comment "branch marker" nodes to distinguish between different branches in the statically-typed view tree
// if one of these marker is hit, then there are two options
// 1) it's the same branch, and we just keep walking until the end
// 2) it's a different branch, in which case the old can be replaced with the new wholesale
else if (oldNode.nodeType === Node.COMMENT_NODE) {
const oldText = oldNode.textContent;
const newText = newNode.textContent;
if(oldText.startsWith("bo") && newText !== oldText) {
oldDocWalker.nextNode();
newDocWalker.nextNode();
const oldRange = new Range();
const newRange = new Range();
let oldBranches = 1;
let newBranches = 1;
while(oldBranches > 0 && newBranches > 0) {
if(oldDocWalker.nextNode() && newDocWalker.nextNode()) {
console.log(oldDocWalker.currentNode, newDocWalker.currentNode);
if(oldDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(oldDocWalker.currentNode.textContent.startsWith("bo")) {
oldBranches += 1;
} else if(oldDocWalker.currentNode.textContent.startsWith("bc")) {
oldBranches -= 1;
}
}
if(newDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(newDocWalker.currentNode.textContent.startsWith("bo")) {
newBranches += 1;
} else if(newDocWalker.currentNode.textContent.startsWith("bc")) {
newBranches -= 1;
}
}
}
}
try {
oldRange.setStartAfter(oldNode);
oldRange.setEndBefore(oldDocWalker.currentNode);
newRange.setStartAfter(newNode);
newRange.setEndBefore(newDocWalker.currentNode);
const newContents = newRange.extractContents();
oldRange.deleteContents();
oldRange.insertNode(newContents);
oldNode.replaceWith(newNode);
oldDocWalker.currentNode.replaceWith(newDocWalker.currentNode);
} catch (e) {
console.error(e);
}
} }
}
};
// Not all browsers support startViewTransition; see https://caniuse.com/?search=startViewTransition
if (document.startViewTransition) {
await document.startViewTransition(transition);
} else {
await transition()
}
window.history.pushState(undefined, null, url);
});

View File

@@ -1,8 +1,13 @@
use leptos::prelude::*;
use leptos_router::{
components::{FlatRoutes, Route, Router},
StaticSegment,
use leptos::{
either::{Either, EitherOf3},
prelude::*,
};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_params_map, use_query_map},
path,
};
use serde::{Deserialize, Serialize};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@@ -12,7 +17,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone()/>
<HydrationScripts options=options islands=true/>
<HydrationScripts options=options islands=true islands_router=true/>
<link rel="stylesheet" id="leptos" href="/pkg/islands.css"/>
<link rel="shortcut icon" type="image/ico" href="/favicon.ico"/>
</head>
@@ -26,34 +31,180 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
#[component]
pub fn App() -> impl IntoView {
view! {
<script src="/routing.js"></script>
<Router>
<header>
<h1>"My Application"</h1>
<h1>"My Contacts"</h1>
</header>
<nav>
<a href="/">"Page A"</a>
<a href="/b">"Page B"</a>
<a href="/">"Home"</a>
<a href="/about">"About"</a>
</nav>
<main>
<p>
<label>"Home Checkbox" <input type="checkbox"/></label>
</p>
<FlatRoutes fallback=|| "Not found.">
<Route path=StaticSegment("") view=PageA/>
<Route path=StaticSegment("b") view=PageB/>
</FlatRoutes>
<Routes fallback=|| "Not found.">
<Route path=path!("") view=Home/>
<Route path=path!("user/:id") view=Details/>
<Route path=path!("about") view=About/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn PageA() -> impl IntoView {
view! { <label>"Page A" <input type="checkbox"/></label> }
#[server]
pub async fn search(query: String) -> Result<Vec<User>, ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let data: Vec<User> = serde_json::from_str(&users)?;
let query = query.to_ascii_lowercase();
Ok(data
.into_iter()
.filter(|user| {
user.first_name.to_ascii_lowercase().contains(&query)
|| user.last_name.to_ascii_lowercase().contains(&query)
|| user.email.to_ascii_lowercase().contains(&query)
})
.collect())
}
#[server]
pub async fn delete_user(id: u32) -> Result<(), ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let mut data: Vec<User> = serde_json::from_str(&users)?;
data.retain(|user| user.id != id);
let new_json = serde_json::to_string(&data)?;
tokio::fs::write("./mock_data.json", &new_json).await?;
Ok(())
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct User {
id: u32,
first_name: String,
last_name: String,
email: String,
}
#[component]
pub fn PageB() -> impl IntoView {
view! { <label>"Page B" <input type="checkbox"/></label> }
pub fn Home() -> impl IntoView {
let q = use_query_map();
let q = move || q.read().get("q");
let data = Resource::new(q, |q| async move {
if let Some(q) = q {
search(q).await
} else {
Ok(vec![])
}
});
let delete_user_action = ServerAction::<DeleteUser>::new();
let view = move || {
Suspend::new(async move {
let users = data.await.unwrap();
if q().is_none() {
EitherOf3::A(view! {
<p class="note">"Enter a search to begin viewing contacts."</p>
})
} else if users.is_empty() {
EitherOf3::B(view! {
<p class="note">"No users found matching that search."</p>
})
} else {
EitherOf3::C(view! {
<table>
<tbody>
<For
each=move || users.clone()
key=|user| user.id
let:user
>
<tr>
<td>{user.first_name}</td>
<td>{user.last_name}</td>
<td>{user.email}</td>
<td>
<a href=format!("/user/{}", user.id)>"Details"</a>
<input type="checkbox"/>
<ActionForm action=delete_user_action>
<input type="hidden" name="id" value=user.id/>
<input type="submit" value="Delete"/>
</ActionForm>
</td>
</tr>
</For>
</tbody>
</table>
})
}
})
};
view! {
<section class="page">
<form method="GET" class="search">
<input type="search" name="q" value=q autofocus oninput="this.form.requestSubmit()"/>
<input type="submit"/>
</form>
<Suspense fallback=|| view! { <p>"Loading..."</p> }>{view}</Suspense>
</section>
}
}
#[component]
pub fn Details() -> impl IntoView {
#[server]
pub async fn get_user(id: u32) -> Result<Option<User>, ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let data: Vec<User> = serde_json::from_str(&users)?;
Ok(data.iter().find(|user| user.id == id).cloned())
}
let params = use_params_map();
let id = move || {
params
.read()
.get("id")
.and_then(|id| id.parse::<u32>().ok())
};
let user = Resource::new(id, |id| async move {
match id {
None => Ok(None),
Some(id) => get_user(id).await,
}
});
move || {
Suspend::new(async move {
user.await.map(|user| match user {
None => Either::Left(view! {
<section class="page">
<h2>"Not found."</h2>
<p>"Sorry — we couldnt find that user."</p>
</section>
}),
Some(user) => Either::Right(view! {
<section class="page">
<h2>{user.first_name} " " { user.last_name}</h2>
<p class="email">{user.email}</p>
</section>
}),
})
})
}
}
#[component]
pub fn About() -> impl IntoView {
view! {
<section class="page">
<h2>"About"</h2>
<p>"This demo is intended to show off an experimental “islands router” feature, which mimics the smooth transitions and user experience of client-side routing while minimizing the amount of code that actually runs in the browser."</p>
<p>"By default, all the content in this application is only rendered on the server. But you can add client-side interactivity via islands like this one:"</p>
<Counter/>
</section>
}
}
#[island]
pub fn Counter() -> impl IntoView {
let count = RwSignal::new(0);
view! {
<button class="counter" on:click=move |_| *count.write() += 1>{count}</button>
}
}

View File

@@ -1,3 +1,52 @@
.pending {
color: purple;
body {
font-family: system-ui, sans-serif;
background-color: #f6f6fa;
}
h1, h2, h3, h4, h5, h6 {
font-family: ui-rounded, 'Hiragino Maru Gothic ProN', Quicksand, Comfortaa, Manjari, 'Arial Rounded MT', 'Arial Rounded MT Bold', Calibri, source-sans-pro, sans-serif;
text-align: center;
}
nav {
padding: 1rem;
text-align: center;
}
nav a {
margin: 1rem;
}
form.search {
display: flex;
margin: 2rem auto;
justify-content: center;
}
td {
min-width: 10rem;
width: 10rem;
}
table {
min-width: 100%;
}
.page {
width: 80%;
margin: auto;
}
td:last-child > * {
display: inline-block;
}
.note, .note {
text-align: center;
}
button.counter {
display: block;
font-size: 2rem;
margin: auto;
}

View File

@@ -149,12 +149,12 @@ pub fn App() -> impl IntoView {
</div>
<div class="col-md-6">
<div class="row">
<Button id="run" text="Create 1,000 rows" on:click=run/>
<Button id="runlots" text="Create 10,000 rows" on:click=run_lots/>
<Button id="add" text="Append 1,000 rows" on:click=add/>
<Button id="update" text="Update every 10th row" on:click=update/>
<Button id="clear" text="Clear" on:click=clear/>
<Button id="swaprows" text="Swap Rows" on:click=swap_rows/>
<Button id="run" text="Create 1,000 rows" on:click=run />
<Button id="runlots" text="Create 10,000 rows" on:click=run_lots />
<Button id="add" text="Append 1,000 rows" on:click=add />
<Button id="update" text="Update every 10th row" on:click=update />
<Button id="clear" text="Clear" on:click=clear />
<Button id="swaprows" text="Swap Rows" on:click=swap_rows />
</div>
</div>
</div>

View File

@@ -9,7 +9,6 @@ use leptos_router::{
},
hooks::{use_navigate, use_params, use_query_map},
params::Params,
MatchNestedRoutes,
};
use leptos_router_macro::path;
use std::time::Duration;
@@ -33,7 +32,7 @@ pub fn RouterExample() -> impl IntoView {
<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)/>
<RoutingProgress is_routing max_time=Duration::from_millis(250) />
</div>
<nav>
// ordinary <a> elements can be used for client-side navigation
@@ -53,15 +52,15 @@ pub fn RouterExample() -> impl IntoView {
<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/>
<Route path=path!("about") view=About />
<ProtectedRoute
path=path!("settings")
condition=move || Some(logged_in.get())
redirect_path=|| "/"
view=Settings
/>
<Route path=path!("redirect-home") view=|| view! { <Redirect path="/"/> }/>
<ContactRoutes/>
<Route path=path!("redirect-home") view=|| view! { <Redirect path="/" /> } />
<ContactRoutes />
</Routes>
</main>
</Router>
@@ -71,11 +70,11 @@ pub fn RouterExample() -> impl IntoView {
// You can define other routes in their own component.
// Routes implement the MatchNestedRoutes
#[component(transparent)]
pub fn ContactRoutes() -> impl MatchNestedRoutes + Clone {
pub fn ContactRoutes() -> impl leptos_router::MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("") view=ContactList>
<Route path=path!("/") view=|| "Select a contact."/>
<Route path=path!("/:id") view=Contact/>
<Route path=path!("/") view=|| "Select a contact." />
<Route path=path!("/:id") view=Contact />
</ParentRoute>
}
.into_inner()
@@ -122,7 +121,7 @@ pub fn ContactList() -> impl IntoView {
<Suspense fallback=move || view! { <p>"Loading contacts..."</p> }>
<ul>{contacts}</ul>
</Suspense>
<Outlet/>
<Outlet />
</div>
}
}
@@ -166,7 +165,7 @@ pub fn Contact() -> impl IntoView {
Some(contact) => Either::Right(view! {
<section class="card">
<h1>{contact.first_name} " " {contact.last_name}</h1>
<p>{contact.address_1} <br/> {contact.address_2}</p>
<p>{contact.address_1} <br /> {contact.address_2}</p>
</section>
}),
}
@@ -224,10 +223,10 @@ pub fn Settings() -> impl IntoView {
<Form action="">
<fieldset>
<legend>"Name"</legend>
<input type="text" name="first_name" placeholder="First"/>
<input type="text" name="last_name" placeholder="Last"/>
<input type="text" name="first_name" placeholder="First" />
<input type="text" name="last_name" placeholder="Last" />
</fieldset>
<input type="submit"/>
<input type="submit" />
<p>
"This uses the " <code>"<Form/>"</code>
" component, which enhances forms by using client-side navigation for "

View File

@@ -21,7 +21,7 @@ server_fn = { path = "../../server_fn", features = [
log = "0.4.22"
simple_logger = "5.0"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.5.2", optional = true }
tower-http = { version = "0.6.2", features = [
"fs",

View File

@@ -1,4 +1,4 @@
use futures::StreamExt;
use futures::{Sink, Stream, StreamExt};
use http::Method;
use leptos::{html::Input, prelude::*, task::spawn_local};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
@@ -9,8 +9,10 @@ use server_fn::{
MultipartFormData, Postcard, Rkyv, SerdeLite, StreamingText,
TextStream,
},
error::{FromServerFnError, IntoAppError, ServerFnErrorErr},
request::{browser::BrowserRequest, ClientReq, Req},
response::{browser::BrowserResponse, ClientRes, Res},
response::{browser::BrowserResponse, ClientRes, TryRes},
ContentType,
};
use std::future::Future;
#[cfg(feature = "ssr")]
@@ -652,32 +654,72 @@ pub fn FileWatcher() -> impl IntoView {
/// implementations if you'd like. However, it's much lighter weight to use something like `strum`
/// simply to generate those trait implementations.
#[server]
pub async fn ascii_uppercase(
text: String,
) -> Result<String, ServerFnError<InvalidArgument>> {
pub async fn ascii_uppercase(text: String) -> Result<String, MyErrors> {
other_error()?;
Ok(ascii_uppercase_inner(text)?)
}
pub fn other_error() -> Result<(), String> {
Ok(())
}
pub fn ascii_uppercase_inner(text: String) -> Result<String, InvalidArgument> {
if text.len() < 5 {
Err(InvalidArgument::TooShort.into())
Err(InvalidArgument::TooShort)
} else if text.len() > 15 {
Err(InvalidArgument::TooLong.into())
Err(InvalidArgument::TooLong)
} else if text.is_ascii() {
Ok(text.to_ascii_uppercase())
} else {
Err(InvalidArgument::NotAscii.into())
Err(InvalidArgument::NotAscii)
}
}
#[server]
pub async fn ascii_uppercase_classic(
text: String,
) -> Result<String, ServerFnError<InvalidArgument>> {
Ok(ascii_uppercase_inner(text)?)
}
// The EnumString and Display derive macros are provided by strum
#[derive(Debug, Clone, EnumString, Display)]
#[derive(Debug, Clone, Display, EnumString, Serialize, Deserialize)]
pub enum InvalidArgument {
TooShort,
TooLong,
NotAscii,
}
#[derive(Debug, Clone, Display, Serialize, Deserialize)]
pub enum MyErrors {
InvalidArgument(InvalidArgument),
ServerFnError(ServerFnErrorErr),
Other(String),
}
impl From<InvalidArgument> for MyErrors {
fn from(value: InvalidArgument) -> Self {
MyErrors::InvalidArgument(value)
}
}
impl From<String> for MyErrors {
fn from(value: String) -> Self {
MyErrors::Other(value)
}
}
impl FromServerFnError for MyErrors {
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
MyErrors::ServerFnError(value)
}
}
#[component]
pub fn CustomErrorTypes() -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
let (result, set_result) = signal(None);
let (result_classic, set_result_classic) = signal(None);
view! {
<h3>Using custom error types</h3>
@@ -692,14 +734,17 @@ pub fn CustomErrorTypes() -> impl IntoView {
<button on:click=move |_| {
let value = input_ref.get().unwrap().value();
spawn_local(async move {
let data = ascii_uppercase(value).await;
let data = ascii_uppercase(value.clone()).await;
let data_classic = ascii_uppercase_classic(value).await;
set_result.set(Some(data));
set_result_classic.set(Some(data_classic));
});
}>
"Submit"
</button>
<p>{move || format!("{:?}", result.get())}</p>
<p>{move || format!("{:?}", result_classic.get())}</p>
}
}
@@ -717,8 +762,11 @@ pub struct Toml;
#[derive(Serialize, Deserialize)]
pub struct TomlEncoded<T>(T);
impl Encoding for Toml {
impl ContentType for Toml {
const CONTENT_TYPE: &'static str = "application/toml";
}
impl Encoding for Toml {
const METHOD: Method = Method::POST;
}
@@ -726,14 +774,12 @@ impl<T, Request, Err> IntoReq<Toml, Request, Err> for TomlEncoded<T>
where
Request: ClientReq<Err>,
T: Serialize,
Err: FromServerFnError,
{
fn into_req(
self,
path: &str,
accepts: &str,
) -> Result<Request, ServerFnError<Err>> {
let data = toml::to_string(&self.0)
.map_err(|e| ServerFnError::Serialization(e.to_string()))?;
fn into_req(self, path: &str, accepts: &str) -> Result<Request, Err> {
let data = toml::to_string(&self.0).map_err(|e| {
ServerFnErrorErr::Serialization(e.to_string()).into_app_error()
})?;
Request::try_new_post(path, Toml::CONTENT_TYPE, accepts, data)
}
}
@@ -742,23 +788,26 @@ impl<T, Request, Err> FromReq<Toml, Request, Err> for TomlEncoded<T>
where
Request: Req<Err> + Send,
T: DeserializeOwned,
Err: FromServerFnError,
{
async fn from_req(req: Request) -> Result<Self, ServerFnError<Err>> {
async fn from_req(req: Request) -> Result<Self, Err> {
let string_data = req.try_into_string().await?;
toml::from_str::<T>(&string_data)
.map(TomlEncoded)
.map_err(|e| ServerFnError::Args(e.to_string()))
.map_err(|e| ServerFnErrorErr::Args(e.to_string()).into_app_error())
}
}
impl<T, Response, Err> IntoRes<Toml, Response, Err> for TomlEncoded<T>
where
Response: Res<Err>,
Response: TryRes<Err>,
T: Serialize + Send,
Err: FromServerFnError,
{
async fn into_res(self) -> Result<Response, ServerFnError<Err>> {
let data = toml::to_string(&self.0)
.map_err(|e| ServerFnError::Serialization(e.to_string()))?;
async fn into_res(self) -> Result<Response, Err> {
let data = toml::to_string(&self.0).map_err(|e| {
ServerFnErrorErr::Serialization(e.to_string()).into_app_error()
})?;
Response::try_from_string(Toml::CONTENT_TYPE, data)
}
}
@@ -767,12 +816,13 @@ impl<T, Response, Err> FromRes<Toml, Response, Err> for TomlEncoded<T>
where
Response: ClientRes<Err> + Send,
T: DeserializeOwned,
Err: FromServerFnError,
{
async fn from_res(res: Response) -> Result<Self, ServerFnError<Err>> {
async fn from_res(res: Response) -> Result<Self, Err> {
let data = res.try_into_string().await?;
toml::from_str(&data)
.map(TomlEncoded)
.map_err(|e| ServerFnError::Deserialization(e.to_string()))
toml::from_str(&data).map(TomlEncoded).map_err(|e| {
ServerFnErrorErr::Deserialization(e.to_string()).into_app_error()
})
}
}
@@ -835,7 +885,10 @@ pub fn CustomClientExample() -> impl IntoView {
pub struct CustomClient;
// Implement the `Client` trait for it.
impl<CustErr> Client<CustErr> for CustomClient {
impl<E> Client<E> for CustomClient
where
E: FromServerFnError,
{
// BrowserRequest and BrowserResponse are the defaults used by other server functions.
// They are wrappers for the underlying Web Fetch API types.
type Request = BrowserRequest;
@@ -844,8 +897,7 @@ pub fn CustomClientExample() -> impl IntoView {
// Our custom `send()` implementation does all the work.
fn send(
req: Self::Request,
) -> impl Future<Output = Result<Self::Response, ServerFnError<CustErr>>>
+ Send {
) -> impl Future<Output = Result<Self::Response, E>> + Send {
// BrowserRequest derefs to the underlying Request type from gloo-net,
// so we can get access to the headers here
let headers = req.headers();
@@ -854,6 +906,24 @@ pub fn CustomClientExample() -> impl IntoView {
// delegate back out to BrowserClient to send the modified request
BrowserClient::send(req)
}
fn open_websocket(
path: &str,
) -> impl Future<
Output = Result<
(
impl Stream<Item = Result<server_fn::Bytes, E>> + Send + 'static,
impl Sink<Result<server_fn::Bytes, E>> + Send + 'static,
),
E,
>,
> + Send {
BrowserClient::open_websocket(path)
}
fn spawn(future: impl Future<Output = ()> + Send + 'static) {
<BrowserClient as Client<E>>::spawn(future)
}
}
// Specify our custom client with `client = `

View File

@@ -20,7 +20,7 @@ leptos_router = { path = "../../router" }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = [

View File

@@ -18,7 +18,7 @@ leptos_router = { path = "../../router" }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = [
@@ -45,7 +45,7 @@ ssr = [
"dep:leptos_axum",
"leptos_router/ssr",
"dep:notify",
"dep:http"
"dep:http",
]
[profile.release]

View File

@@ -4,7 +4,7 @@ use leptos_router::{
hooks::use_params,
nested_router::Outlet,
params::Params,
MatchNestedRoutes, ParamSegment, SsrMode, StaticSegment, WildcardSegment,
ParamSegment, SsrMode, StaticSegment, WildcardSegment,
};
#[cfg(feature = "ssr")]
@@ -21,6 +21,7 @@ pub(super) mod counter {
pub struct Counter(AtomicU32);
impl Counter {
#[allow(dead_code)]
pub const fn new() -> Self {
Self(AtomicU32::new(0))
}
@@ -203,20 +204,20 @@ pub struct SuspenseCounters {
}
#[component]
pub fn InstrumentedRoutes() -> impl MatchNestedRoutes + Clone {
pub fn InstrumentedRoutes() -> impl leptos_router::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/>
<Route path=StaticSegment("/") view=InstrumentedTop />
<ParentRoute path=StaticSegment("item") view=ItemRoot>
<Route path=StaticSegment("/") view=ItemListing/>
<Route path=StaticSegment("/") view=ItemListing />
<ParentRoute path=ParamSegment("id") view=ItemTop>
<Route path=StaticSegment("/") view=ItemOverview/>
<Route path=WildcardSegment("path") view=ItemInspect/>
<Route path=StaticSegment("/") view=ItemOverview />
<Route path=WildcardSegment("path") view=ItemInspect />
</ParentRoute>
</ParentRoute>
<Route path=StaticSegment("counters") view=ShowCounters/>
<Route path=StaticSegment("counters") view=ShowCounters />
</ParentRoute>
}
.into_inner()
@@ -279,32 +280,41 @@ fn InstrumentedRoot() -> impl IntoView {
<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>
<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 {
<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>
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>
@@ -323,11 +333,17 @@ fn InstrumentedRoot() -> impl IntoView {
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>
<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>
<li>
<a href="item/">"Item Listing"</a>
</li>
<li>
<a href="item/4/path1/">"Target 41#"</a>
</li>
</ul>
}
}
@@ -342,7 +358,7 @@ fn ItemRoot() -> impl IntoView {
view! {
<h2>"<ItemRoot/>"</h2>
<Outlet/>
<Outlet />
}
}
@@ -360,7 +376,9 @@ fn ItemListing() -> impl IntoView {
// 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>
<li>
<a href=format!("/instrumented/item/{item}/")>"Item "{item}</a>
</li>
}
)
.collect_view()
@@ -373,9 +391,7 @@ fn ItemListing() -> impl IntoView {
view! {
<h3>"<ItemListing/>"</h3>
<ul>
<Suspense>
{item_listing}
</Suspense>
<Suspense>{item_listing}</Suspense>
</ul>
}
}
@@ -402,7 +418,7 @@ fn ItemTop() -> impl IntoView {
));
view! {
<h4>"<ItemTop/>"</h4>
<Outlet/>
<Outlet />
}
}
@@ -412,24 +428,29 @@ fn ItemOverview() -> impl IntoView {
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>
});
let result = resource.await.map(|GetItemResult(item, names)| {
view! {
<p>{format!("Viewing {item:?}")}</p>
<ul>
{names
.into_iter()
.map(|name| {
let id = item.id;
// 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>
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
})
@@ -437,9 +458,7 @@ fn ItemOverview() -> impl IntoView {
view! {
<h5>"<ItemOverview/>"</h5>
<Suspense>
{item_view}
</Suspense>
<Suspense>{item_view}</Suspense>
}
}
@@ -496,23 +515,26 @@ fn ItemInspect() -> impl IntoView {
));
view! {
<p>{format!("Inspecting {item:?}")}</p>
<ul>{
fields.iter()
<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>
// <li><A href=format!("{field}")>{text}</A></li>
// }
view! {
<li><a href=format!("/instrumented/item/{id}/{name}/{field}")>{
format!("Inspect {name}/{field}")
}</a></li>
<li>
<a href=format!(
"/instrumented/item/{id}/{name}/{field}",
)>{format!("Inspect {name}/{field}")}</a>
</li>
}
})
.collect_view()
}</ul>
.collect_view()}
</ul>
}
});
suspense_counters.update_untracked(|c| c.item_inspect += 1);
@@ -527,9 +549,7 @@ fn ItemInspect() -> impl IntoView {
view! {
<h5>"<ItemInspect/>"</h5>
<Suspense>
{inspect_view}
</Suspense>
<Suspense>{inspect_view}</Suspense>
}
}
@@ -590,7 +610,8 @@ fn ShowCounters() -> impl IntoView {
id="reset-counters"
type="submit"
value="Reset Counters"
on:click=clear_suspense_counters/>
on:click=clear_suspense_counters
/>
</ActionForm>
}
})
@@ -601,20 +622,23 @@ fn ShowCounters() -> impl IntoView {
<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>
})}
{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>
<Suspense>{counter_view}</Suspense>
}
}
@@ -642,17 +666,17 @@ pub fn FieldNavPortlet() -> impl IntoView {
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>
<nav>
{ctx
.0
.map(|ctx| {
ctx.into_iter()
.map(|FieldNavItem { href, text }| {
view! { <A href=href>{text}</A> }
})
.collect_view()
})}
</nav>
</div>
}
})

View File

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
console_error_panic_hook = "0.1.7"
leptos = { path = "../../leptos" }
leptos_meta = { path = "../../meta" }

22
examples/tailwind_axum/package-lock.json generated Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "leptos-tailwind",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "leptos-tailwind",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"tailwindcss": "^4.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz",
"integrity": "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==",
"dev": true
}
}
}

View File

@@ -0,0 +1,69 @@
/*! tailwindcss v4.0.0 | MIT License | https://tailwindcss.com */
@tailwind base;
@tailwind components;
.relative {
position: relative;
}
.m-auto {
margin: auto;
}
.flex {
display: flex;
}
.min-h-screen {
min-height: 100vh;
}
.transform {
transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y);
}
.flex-col {
flex-direction: column;
}
.flex-row-reverse {
flex-direction: row-reverse;
}
.flex-wrap {
flex-wrap: wrap;
}
.border-b-4 {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 4px;
}
.border-l-2 {
border-left-style: var(--tw-border-style);
border-left-width: 2px;
}
.bg-gradient-to-tl {
--tw-gradient-position: to top left in oklab,;
background-image: linear-gradient(var(--tw-gradient-stops));
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
initial-value: rotateX(0);
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
initial-value: rotateY(0);
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
initial-value: rotateZ(0);
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
initial-value: skewX(0);
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
initial-value: skewY(0);
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}

View File

@@ -16,7 +16,7 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
log = "0.4.22"
simple_logger = "5.0"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }

View File

@@ -1,4 +1,4 @@
use crate::todo::*;
#[cfg(feature = "ssr")]
use axum::{
body::Body,
extract::Path,
@@ -8,10 +8,9 @@ use axum::{
Router,
};
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use todo_app_sqlite_axum::*;
//Define a handler to test extractor with state
#[cfg(feature = "ssr")]
async fn custom_handler(
Path(id): Path<String>,
req: Request<Body>,
@@ -20,14 +19,16 @@ async fn custom_handler(
move || {
provide_context(id.clone());
},
TodoApp,
todo::TodoApp,
);
handler(req).await.into_response()
}
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use crate::todo::ssr::db;
use crate::todo::{ssr::db, *};
use leptos_axum::{generate_route_list, LeptosRoutes};
simple_logger::init_with_level(log::Level::Error)
.expect("couldn't initialize logging");
@@ -45,7 +46,7 @@ async fn main() {
// build our application with a route
let app = Router::new()
.route("/special/:id", get(custom_handler))
.route("/special/{id}", get(custom_handler))
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
@@ -61,3 +62,12 @@ async fn main() {
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
use leptos::mount::mount_to_body;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(todo::TodoApp);
}

View File

@@ -15,7 +15,7 @@ leptos_meta = { path = "../../meta" }
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 }
axum = { version = "0.8.1", 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 }

View File

@@ -34,7 +34,7 @@ async fn main() {
// here, we're not actually doing server side rendering, so we set up a manual
// handler for the server fns
// this should include a get() handler if you have any GetUrl-based server fns
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
.route("/api/{*fn_name}", post(leptos_axum::handle_server_fns))
.fallback(file_or_index_handler)
.with_state(leptos_options);

View File

@@ -1,6 +1,6 @@
[package]
name = "hydration_context"
version = "0.2.1"
version = "0.3.0"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -21,6 +21,7 @@ leptos_macro = { workspace = true, features = ["actix"] }
leptos_meta = { workspace = true, features = ["nonce"] }
leptos_router = { workspace = true, features = ["ssr"] }
server_fn = { workspace = true, features = ["actix"] }
tachys = { workspace = true }
serde_json = { workspace = true }
parking_lot = "0.12.3"
tracing = { version = "0.1", optional = true }
@@ -33,7 +34,7 @@ once_cell = "1"
rustdoc-args = ["--generate-link-to-definition"]
[features]
dont-use-islands-router = []
islands-router = ["tachys/islands"]
tracing = ["dep:tracing"]
[package.metadata.cargo-all-features]

View File

@@ -23,6 +23,7 @@ use hydration_context::SsrSharedContext;
use leptos::{
config::LeptosOptions,
context::{provide_context, use_context},
hydration::IslandsRouterNavigation,
prelude::expect_context,
reactive::{computed::ScopedFuture, owner::Owner},
IntoView,
@@ -367,7 +368,6 @@ pub fn handle_server_fns_with_context(
// actually run the server fn
let mut res = ActixResponse(
service
.0
.run(ActixRequest::from((req, payload)))
.await
.take(),
@@ -655,12 +655,27 @@ where
IV: IntoView + 'static,
{
_ = replace_blocks; // TODO
handle_response(method, additional_context, app_fn, |app, chunks| {
Box::pin(async move {
Box::pin(app.to_html_stream_out_of_order().chain(chunks()))
as PinnedStream<String>
})
})
handle_response(
method,
additional_context,
app_fn,
|app, chunks, supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "islands-router") {
if supports_ooo {
app.to_html_stream_out_of_order_branching()
} else {
app.to_html_stream_in_order_branching()
}
} else if supports_ooo {
app.to_html_stream_out_of_order()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
},
)
}
/// Returns an Actix [struct@Route](actix_web::Route) that listens for a `GET` request and tries
@@ -686,12 +701,21 @@ pub fn render_app_to_stream_in_order_with_context<IV>(
where
IV: IntoView + 'static,
{
handle_response(method, additional_context, app_fn, |app, chunks| {
Box::pin(async move {
Box::pin(app.to_html_stream_in_order().chain(chunks()))
as PinnedStream<String>
})
})
handle_response(
method,
additional_context,
app_fn,
|app, chunks, _supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
},
)
}
/// Returns an Actix [struct@Route](actix_web::Route) that listens for a `GET` request and tries
@@ -723,12 +747,13 @@ where
fn async_stream_builder<IV>(
app: IV,
chunks: BoxedFnOnce<PinnedStream<String>>,
_supports_ooo: bool,
) -> PinnedFuture<PinnedStream<String>>
where
IV: IntoView + 'static,
{
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -768,6 +793,7 @@ fn leptos_corrected_path(req: &HttpRequest) -> String {
}
}
#[allow(clippy::type_complexity)]
fn handle_response<IV>(
method: Method,
additional_context: impl Fn() + 'static + Clone + Send,
@@ -775,6 +801,7 @@ fn handle_response<IV>(
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
) -> Route
where
@@ -785,6 +812,9 @@ where
let add_context = additional_context.clone();
async move {
let is_island_router_navigation = cfg!(feature = "islands-router")
&& req.headers().get("Islands-Router").is_some();
let res_options = ResponseOptions::default();
let (meta_context, meta_output) = ServerMetaContext::new();
@@ -795,6 +825,10 @@ where
move || {
provide_contexts(req, &meta_context, &res_options);
add_context();
if is_island_router_navigation {
provide_context(IslandsRouterNavigation);
}
}
};
@@ -804,6 +838,7 @@ where
additional_context,
res_options,
stream_builder,
!is_island_router_navigation,
)
.await;
@@ -1094,6 +1129,7 @@ impl StaticRouteGenerator {
app_fn.clone(),
additional_context,
async_stream_builder,
false,
);
let sc = owner.shared_context().unwrap();

View File

@@ -4,14 +4,14 @@ authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Axum integrations for the Leptos web framework."
version = { workspace = true }
version = "0.8.0-alpha2"
rust-version.workspace = true
edition.workspace = true
[dependencies]
any_spawner = { workspace = true, features = ["tokio"] }
hydration_context = { workspace = true }
axum = { version = "0.7.9", default-features = false, features = [
axum = { version = "0.8.1", default-features = false, features = [
"matched-path",
] }
dashmap = "6"
@@ -22,6 +22,7 @@ leptos_macro = { workspace = true, features = ["axum"] }
leptos_meta = { workspace = true, features = ["ssr", "nonce"] }
leptos_router = { workspace = true, features = ["ssr"] }
leptos_integration_utils = { workspace = true }
tachys = { workspace = true }
once_cell = "1"
parking_lot = "0.12.3"
tokio = { version = "1.43", default-features = false }
@@ -30,13 +31,19 @@ tower-http = "0.6.2"
tracing = { version = "0.1.41", optional = true }
[dev-dependencies]
axum = "0.7.9"
axum = "0.8.1"
tokio = { version = "1.43", features = ["net", "rt-multi-thread"] }
[features]
wasm = []
default = ["tokio/fs", "tokio/sync", "tower-http/fs", "tower/util"]
dont-use-islands-router = []
default = [
"tokio/fs",
"tokio/sync",
"tower-http/fs",
"tower/util",
"server_fn/axum",
]
islands-router = ["tachys/islands"]
tracing = ["dep:tracing"]
[package.metadata.docs.rs]

View File

@@ -370,8 +370,6 @@ async fn handle_server_fns_inner(
additional_context: impl Fn() + 'static + Clone + Send,
req: Request<Body>,
) -> impl IntoResponse {
use server_fn::middleware::Service;
let method = req.method().clone();
let path = req.uri().path().to_string();
let (req, parts) = generate_request_and_parts(req);
@@ -487,7 +485,7 @@ pub type PinnedHtmlStream =
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream<IV>(
app_fn: impl Fn() -> IV + Clone + Send + 'static,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
@@ -511,7 +509,7 @@ where
)]
pub fn render_route<S, IV>(
paths: Vec<AxumRouteListing>,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
State<S>,
Request<Body>,
@@ -576,7 +574,7 @@ where
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_in_order<IV>(
app_fn: impl Fn() -> IV + Clone + Send + 'static,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
@@ -629,13 +627,14 @@ where
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_with_context<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
+ Clone
+ Send
+ Sync
+ 'static
where
IV: IntoView + 'static,
@@ -658,8 +657,8 @@ where
)]
pub fn render_route_with_context<S, IV>(
paths: Vec<AxumRouteListing>,
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
State<S>,
Request<Body>,
@@ -760,25 +759,32 @@ where
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_with_context_and_replace_blocks<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
replace_blocks: bool,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
+ Clone
+ Send
+ Sync
+ 'static
where
IV: IntoView + 'static,
{
_ = replace_blocks; // TODO
handle_response(additional_context, app_fn, |app, chunks| {
handle_response(additional_context, app_fn, |app, chunks, supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
app.to_html_stream_out_of_order_branching()
} else {
let app = if cfg!(feature = "islands-router") {
if supports_ooo {
app.to_html_stream_out_of_order_branching()
} else {
app.to_html_stream_in_order_branching()
}
} else if supports_ooo {
app.to_html_stream_out_of_order()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
@@ -827,8 +833,8 @@ where
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_in_order_with_context<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
@@ -838,8 +844,8 @@ pub fn render_app_to_stream_in_order_with_context<IV>(
where
IV: IntoView + 'static,
{
handle_response(additional_context, app_fn, |app, chunks| {
let app = if cfg!(feature = "dont-use-islands-router") {
handle_response(additional_context, app_fn, |app, chunks, _supports_ooo| {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -851,13 +857,18 @@ where
}
fn handle_response<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
) -> impl Fn(Request<Body>) -> PinnedFuture<Response<Body>> + Clone + Send + 'static
) -> impl Fn(Request<Body>) -> PinnedFuture<Response<Body>>
+ Clone
+ Send
+ Sync
+ 'static
where
IV: IntoView + 'static,
{
@@ -875,12 +886,16 @@ fn handle_response_inner<IV>(
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
) -> PinnedFuture<Response<Body>>
where
IV: IntoView + 'static,
{
Box::pin(async move {
let is_island_router_navigation = cfg!(feature = "islands-router")
&& req.headers().get("Islands-Router").is_some();
let add_context = additional_context.clone();
let res_options = ResponseOptions::default();
let (meta_context, meta_output) = ServerMetaContext::new();
@@ -902,6 +917,10 @@ where
res_options.clone(),
);
add_context();
if is_island_router_navigation {
provide_context(IslandsRouterNavigation);
}
}
};
@@ -911,6 +930,7 @@ where
additional_context,
res_options,
stream_builder,
!is_island_router_navigation,
)
.await;
@@ -985,7 +1005,7 @@ fn provide_contexts(
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_async<IV>(
app_fn: impl Fn() -> IV + Clone + Send + 'static,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
@@ -1039,8 +1059,8 @@ where
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_async_stream_with_context<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
@@ -1050,9 +1070,9 @@ pub fn render_app_async_stream_with_context<IV>(
where
IV: IntoView + 'static,
{
handle_response(additional_context, app_fn, |app, chunks| {
handle_response(additional_context, app_fn, |app, chunks, _supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -1106,8 +1126,8 @@ where
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_async_with_context<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> impl Fn(
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
@@ -1123,12 +1143,13 @@ where
fn async_stream_builder<IV>(
app: IV,
chunks: BoxedFnOnce<PinnedStream<String>>,
_supports_ooo: bool,
) -> PinnedFuture<PinnedStream<String>>
where
IV: IntoView + 'static,
{
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -1401,6 +1422,7 @@ impl StaticRouteGenerator {
app_fn.clone(),
additional_context,
async_stream_builder,
false,
);
let sc = owner.shared_context().unwrap();
@@ -1646,7 +1668,7 @@ where
self,
options: &S,
paths: Vec<AxumRouteListing>,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> Self
where
IV: IntoView + 'static;
@@ -1661,8 +1683,8 @@ where
self,
options: &S,
paths: Vec<AxumRouteListing>,
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> Self
where
IV: IntoView + 'static;
@@ -1695,12 +1717,15 @@ impl AxumPath for Vec<PathSegment> {
match segment {
PathSegment::Static(s) => path.push_str(s),
PathSegment::Param(s) => {
path.push(':');
path.push('{');
path.push_str(s);
path.push('}');
}
PathSegment::Splat(s) => {
path.push('{');
path.push('*');
path.push_str(s);
path.push('}');
}
PathSegment::Unit => {}
PathSegment::OptionalParam(_) => {
@@ -1732,7 +1757,7 @@ where
self,
state: &S,
paths: Vec<AxumRouteListing>,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> Self
where
IV: IntoView + 'static,
@@ -1748,8 +1773,8 @@ where
self,
state: &S,
paths: Vec<AxumRouteListing>,
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + 'static + Clone + Send + Sync,
app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static,
) -> Self
where
IV: IntoView + 'static,
@@ -1830,64 +1855,64 @@ where
}
} else {
router.route(
path,
match listing.mode() {
SsrMode::OutOfOrder => {
let s = render_app_to_stream_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
path,
match listing.mode() {
SsrMode::OutOfOrder => {
let s = render_app_to_stream_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
SsrMode::PartiallyBlocked => {
let s = render_app_to_stream_with_context_and_replace_blocks(
cx_with_state_and_method.clone(),
app_fn.clone(),
true
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
SsrMode::PartiallyBlocked => {
let s = render_app_to_stream_with_context_and_replace_blocks(
cx_with_state_and_method.clone(),
app_fn.clone(),
true
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
SsrMode::InOrder => {
let s = render_app_to_stream_in_order_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
SsrMode::InOrder => {
let s = render_app_to_stream_in_order_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
SsrMode::Async => {
let s = render_app_async_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
SsrMode::Async => {
let s = render_app_async_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
_ => unreachable!()
},
)
_ => unreachable!()
},
)
};
}
}
@@ -2021,7 +2046,7 @@ where
},
move || shell(options),
req,
|app, chunks| {
|app, chunks, _supports_ooo| {
Box::pin(async move {
let app = app
.to_html_stream_in_order()

View File

@@ -1,3 +1,5 @@
#![allow(clippy::type_complexity)]
use futures::{stream::once, Stream, StreamExt};
use hydration_context::{SharedContext, SsrSharedContext};
use leptos::{
@@ -31,14 +33,20 @@ pub trait ExtendResponse: Sized {
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
supports_ooo: bool,
) -> impl Future<Output = Self> + Send
where
IV: IntoView + 'static,
{
async move {
let (owner, stream) =
build_response(app_fn, additional_context, stream_builder);
let (owner, stream) = build_response(
app_fn,
additional_context,
stream_builder,
supports_ooo,
);
let sc = owner.shared_context().unwrap();
@@ -94,7 +102,11 @@ pub fn build_response<IV>(
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
// this argument indicates whether a request wants to support out-of-order streaming
// responses
bool,
) -> PinnedFuture<PinnedStream<String>>,
is_islands_router_navigation: bool,
) -> (Owner, PinnedFuture<PinnedStream<String>>)
where
IV: IntoView + 'static,
@@ -138,7 +150,7 @@ where
//
// we also don't actually start hydrating until after the whole stream is complete,
// so it's not useful to send those scripts down earlier.
stream_builder(app, chunks)
stream_builder(app, chunks, is_islands_router_navigation)
});
stream.await

View File

@@ -42,11 +42,7 @@ 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 = [
"form-redirects",
"browser",
"url",
] }
server_fn = { workspace = true, features = ["form-redirects", "browser"] }
web-sys = { version = "0.3.72", features = [
"ShadowRoot",
"ShadowRootInit",
@@ -100,6 +96,15 @@ trace-component-props = [
]
delegation = ["tachys/delegation"]
# Having an erasure feature rather than normal --cfg erase_components for the proc macro crate is a workaround for this rust issue:
# https://github.com/rust-lang/cargo/issues/4423
# TLDR proc macros will ignore RUSTFLAGS when --target is specified on the cargo command.
# This works around the issue by the non proc-macro crate which does see RUSTFLAGS enabling the replacement feature on the proc-macro crate, which wouldn't.
# This is automatic as long as the leptos crate is depended upon,
# downstream usage should never manually enable this feature.
[target.'cfg(erase_components)'.dependencies]
leptos_macro = { workspace = true, features = ["__internal_erase_components"] }
[package.metadata.cargo-all-features]
denylist = [
"nightly",

View File

@@ -43,7 +43,7 @@ pub fn AttributeInterceptor<Chil, T>(
) -> impl IntoView
where
Chil: Fn(AnyAttribute) -> T + Send + Sync + 'static,
T: IntoView,
T: IntoView + 'static,
{
AttributeInterceptorInner::new(children)
}
@@ -86,7 +86,7 @@ impl<T: IntoView, A: Attribute> Render for AttributeInterceptorInner<T, A> {
}
}
impl<T: IntoView, A> AddAnyAttr for AttributeInterceptorInner<T, A>
impl<T: IntoView + 'static, A> AddAnyAttr for AttributeInterceptorInner<T, A>
where
A: Attribute,
{
@@ -114,8 +114,11 @@ where
}
}
impl<T: IntoView, A: Attribute> RenderHtml for AttributeInterceptorInner<T, A> {
impl<T: IntoView + 'static, A: Attribute> RenderHtml
for AttributeInterceptorInner<T, A>
{
type AsyncOutput = T::AsyncOutput;
type Owned = AttributeInterceptorInner<T, A::CloneableOwned>;
const MIN_LENGTH: usize = T::MIN_LENGTH;
@@ -135,9 +138,15 @@ impl<T: IntoView, A: Attribute> RenderHtml for AttributeInterceptorInner<T, A> {
position: &mut leptos::tachys::view::Position,
escape: bool,
mark_branches: bool,
_extra_attrs: Vec<AnyAttribute>,
) {
self.children
.to_html_with_buf(buf, position, escape, mark_branches)
self.children.to_html_with_buf(
buf,
position,
escape,
mark_branches,
vec![],
)
}
fn hydrate<const FROM_SERVER: bool>(
@@ -147,4 +156,12 @@ impl<T: IntoView, A: Attribute> RenderHtml for AttributeInterceptorInner<T, A> {
) -> Self::State {
self.children.hydrate::<FROM_SERVER>(cursor, position)
}
fn into_owned(self) -> Self::Owned {
AttributeInterceptorInner {
children_builder: self.children_builder,
children: self.children,
attributes: self.attributes.into_cloneable_owned(),
}
}
}

View File

@@ -43,13 +43,20 @@
use reactive_graph::{
owner::{LocalStorage, StoredValue},
traits::WithValue,
traits::{Dispose, WithValue},
};
use std::{fmt, rc::Rc, sync::Arc};
/// A wrapper trait for calling callbacks.
pub trait Callable<In: 'static, Out: 'static = ()> {
/// calls the callback with the specified argument.
///
/// Returns None if the callback has been disposed
fn try_run(&self, input: In) -> Option<Out>;
/// calls the callback with the specified argument.
///
/// # Panics
/// Panics if you try to run a callback that has been disposed
fn run(&self, input: In) -> Out;
}
@@ -72,6 +79,12 @@ impl<In, Out> Clone for UnsyncCallback<In, Out> {
}
}
impl<In, Out> Dispose for UnsyncCallback<In, Out> {
fn dispose(self) {
self.0.dispose();
}
}
impl<In, Out> UnsyncCallback<In, Out> {
/// Creates a new callback from the given function.
pub fn new<F>(f: F) -> UnsyncCallback<In, Out>
@@ -93,6 +106,10 @@ impl<In, Out> UnsyncCallback<In, Out> {
}
impl<In: 'static, Out: 'static> Callable<In, Out> for UnsyncCallback<In, Out> {
fn try_run(&self, input: In) -> Option<Out> {
self.0.try_with_value(|fun| fun(input))
}
fn run(&self, input: In) -> Out {
self.0.with_value(|fun| fun(input))
}
@@ -168,10 +185,12 @@ impl<In, Out> fmt::Debug for Callback<In, Out> {
}
impl<In, Out> Callable<In, Out> for Callback<In, Out> {
fn try_run(&self, input: In) -> Option<Out> {
self.0.try_with_value(|fun| fun(input))
}
fn run(&self, input: In) -> Out {
self.0
.try_with_value(|f| f(input))
.expect("called a callback that has been disposed")
self.0.with_value(|f| f(input))
}
}
@@ -181,6 +200,12 @@ impl<In, Out> Clone for Callback<In, Out> {
}
}
impl<In, Out> Dispose for Callback<In, Out> {
fn dispose(self) {
self.0.dispose();
}
}
impl<In, Out> Copy for Callback<In, Out> {}
macro_rules! impl_callable_from_fn {
@@ -239,7 +264,9 @@ impl<In: 'static, Out: 'static> Callback<In, Out> {
#[cfg(test)]
mod tests {
use super::Callable;
use crate::callback::{Callback, UnsyncCallback};
use reactive_graph::traits::Dispose;
struct NoClone {}
@@ -270,6 +297,22 @@ mod tests {
(|num, s| format!("{num} {s}")).into();
}
#[test]
fn sync_callback_try_run() {
let callback = Callback::new(move |arg| arg);
assert_eq!(callback.try_run((0,)), Some((0,)));
callback.dispose();
assert_eq!(callback.try_run((0,)), None);
}
#[test]
fn unsync_callback_try_run() {
let callback = UnsyncCallback::new(move |arg| arg);
assert_eq!(callback.try_run((0,)), Some((0,)));
callback.dispose();
assert_eq!(callback.try_run((0,)), None);
}
#[test]
fn callback_matches_same() {
let callback1 = Callback::new(|x: i32| x * 2);

View File

@@ -11,7 +11,7 @@ use reactive_graph::{
use rustc_hash::FxHashMap;
use std::{fmt::Debug, sync::Arc};
use tachys::{
html::attribute::Attribute,
html::attribute::{any_attribute::AnyAttribute, Attribute},
hydration::Cursor,
reactive_graph::OwnedView,
ssr::StreamBuilder,
@@ -163,6 +163,14 @@ where
self.children.insert_before_this(child)
}
}
fn elements(&self) -> Vec<tachys::renderer::types::Element> {
if let Some(fallback) = &self.fallback {
fallback.elements()
} else {
self.children.elements()
}
}
}
impl<Chil, FalFn, Fal> Render for ErrorBoundaryView<Chil, FalFn>
@@ -268,6 +276,7 @@ where
Fal: RenderHtml + Send + 'static,
{
type AsyncOutput = ErrorBoundaryView<Chil::AsyncOutput, FalFn>;
type Owned = Self;
const MIN_LENGTH: usize = Chil::MIN_LENGTH;
@@ -301,6 +310,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
// first, attempt to serialize the children to HTML, then check for errors
let _hook = throw_error::set_error_hook(self.hook);
@@ -311,6 +321,7 @@ where
&mut new_pos,
escape,
mark_branches,
extra_attrs.clone(),
);
// any thrown errors would've been caught here
@@ -323,6 +334,7 @@ where
position,
escape,
mark_branches,
extra_attrs,
);
}
}
@@ -333,6 +345,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
@@ -345,6 +358,7 @@ where
&mut new_pos,
escape,
mark_branches,
extra_attrs.clone(),
);
// any thrown errors would've been caught here
@@ -358,6 +372,7 @@ where
position,
escape,
mark_branches,
extra_attrs,
);
buf.push_sync(&fallback);
}
@@ -423,6 +438,10 @@ where
},
)
}
fn into_owned(self) -> Self::Owned {
self
}
}
#[derive(Debug)]

View File

@@ -6,7 +6,10 @@ use reactive_graph::{
traits::Set,
};
use std::hash::Hash;
use tachys::{reactive_graph::OwnedView, view::keyed::keyed};
use tachys::{
reactive_graph::OwnedView,
view::keyed::{keyed, SerializableKey},
};
/// Iterates over children and displays them, keyed by the `key` function given.
///
@@ -121,7 +124,7 @@ where
EF: Fn(T) -> N + Send + Clone + 'static,
N: IntoView + 'static,
KF: Fn(&T) -> K + Send + Clone + 'static,
K: Eq + Hash + 'static,
K: Eq + Hash + SerializableKey + 'static,
T: Send + 'static,
{
// this takes the owner of the For itself
@@ -195,7 +198,7 @@ where
EF: Fn(ReadSignal<usize>, T) -> N + Send + Clone + 'static,
N: IntoView + 'static,
KF: Fn(&T) -> K + Send + Clone + 'static,
K: Eq + Hash + 'static,
K: Eq + Hash + SerializableKey + 'static,
T: Send + 'static,
{
// this takes the owner of the For itself
@@ -218,6 +221,7 @@ where
};
move || keyed(each(), key.clone(), children.clone())
}
/*
#[cfg(test)]
mod tests {

View File

@@ -3,7 +3,11 @@ use leptos_dom::helpers::window;
use leptos_server::{ServerAction, ServerMultiAction};
use serde::de::DeserializeOwned;
use server_fn::{
client::Client, codec::PostUrl, request::ClientReq, ServerFn, ServerFnError,
client::Client,
codec::PostUrl,
error::{IntoAppError, ServerFnErrorErr},
request::ClientReq,
Http, ServerFn,
};
use tachys::{
either::Either,
@@ -71,7 +75,7 @@ use web_sys::{
/// ```
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip_all))]
#[component]
pub fn ActionForm<ServFn>(
pub fn ActionForm<ServFn, OutputProtocol>(
/// The action from which to build the form.
action: ServerAction<ServFn>,
/// A [`NodeRef`] in which the `<form>` element should be stored.
@@ -82,7 +86,7 @@ pub fn ActionForm<ServFn>(
) -> impl IntoView
where
ServFn: DeserializeOwned
+ ServerFn<InputEncoding = PostUrl>
+ ServerFn<Protocol = Http<PostUrl, OutputProtocol>>
+ Clone
+ Send
+ Sync
@@ -121,9 +125,10 @@ where
"Error converting form field into server function \
arguments: {err:?}"
);
value.set(Some(Err(ServerFnError::Serialization(
value.set(Some(Err(ServerFnErrorErr::Serialization(
err.to_string(),
))));
)
.into_app_error())));
version.update(|n| *n += 1);
}
}
@@ -146,7 +151,7 @@ where
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.
#[component]
pub fn MultiActionForm<ServFn>(
pub fn MultiActionForm<ServFn, OutputProtocol>(
/// The action from which to build the form.
action: ServerMultiAction<ServFn>,
/// A [`NodeRef`] in which the `<form>` element should be stored.
@@ -160,7 +165,7 @@ where
+ Sync
+ Clone
+ DeserializeOwned
+ ServerFn<InputEncoding = PostUrl>
+ ServerFn<Protocol = Http<PostUrl, OutputProtocol>>
+ 'static,
ServFn::Output: Send + Sync + 'static,
<<ServFn::Client as Client<ServFn::Error>>::Request as ClientReq<
@@ -187,9 +192,10 @@ where
action.dispatch(new_input);
}
Err(err) => {
action.dispatch_sync(Err(ServerFnError::Serialization(
action.dispatch_sync(Err(ServerFnErrorErr::Serialization(
err.to_string(),
)));
)
.into_app_error()));
}
}
};

View File

@@ -52,6 +52,8 @@
mod.hydrate();
hydrateIslands(document.body, mod);
});
window.__hydrateIsland = (el, id) => hydrateIsland(el, id, mod);
})
});
})

View File

@@ -0,0 +1,378 @@
let NAVIGATION = 0;
window.addEventListener("click", async (ev) => {
const req = clickToReq(ev);
if(!req) {
return;
}
ev.preventDefault();
await navigateToPage(req, true);
});
window.addEventListener("popstate", async (ev) => {
const req = new Request(window.location);
ev.preventDefault();
await navigateToPage(req, true, true);
});
window.addEventListener("submit", async (ev) => {
const req = submitToReq(ev);
if(!req) {
return;
}
ev.preventDefault();
await navigateToPage(req, true);
});
async function navigateToPage(
/** @type Request */
req,
/** @type bool */
useViewTransition,
/** @type bool */
replace
) {
NAVIGATION += 1;
const currentNav = NAVIGATION;
// add a custom header to indicate that we're on a subsequent navigation
req.headers.append("Islands-Router", "true");
// fetch the new page
const resp = await fetch(req);
const redirected = resp.redirected;
const htmlString = await resp.text();
if(NAVIGATION === currentNav) {
// The 'doc' variable now contains the parsed DOM
const transition = async () => {
try {
diffPages(htmlString);
for(const island of document.querySelectorAll("leptos-island")) {
if(!island.$$hydrated) {
__hydrateIsland(island, island.dataset.component);
island.$$hydrated = true;
}
}
} catch(e) {
console.error(e);
}
};
// Not all browsers support startViewTransition; see https://caniuse.com/?search=startViewTransition
if (useViewTransition && document.startViewTransition) {
await document.startViewTransition(transition);
} else {
await transition()
}
const url = redirected ? resp.url : req.url;
if(replace) {
window.history.replaceState(undefined, null, url);
} else {
window.history.pushState(undefined, null, url);
}
}
}
function clickToReq(ev) {
// confirm that this is an <a> that meets our requirements
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.altKey ||
ev.ctrlKey ||
ev.shiftKey
)
return;
/** @type HTMLAnchorElement | undefined;*/
const a = ev
.composedPath()
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
if (!a) return;
const svg = a.namespaceURI === "http://www.w3.org/2000/svg";
const href = svg ? a.href.baseVal : a.href;
const target = svg ? a.target.baseVal : a.target;
if (target || (!href && !a.hasAttribute("state"))) return;
const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel?.includes("external"))) return;
const url = svg ? new URL(href, document.baseURI) : new URL(href);
if (
url.origin !== window.location.origin // ||
// TODO base
//(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase()))
)
return;
return new Request(url);
}
function submitToReq(ev) {
event.preventDefault();
const target = ev.target;
/** @type HTMLFormElement */
let form;
if(target instanceof HTMLFormElement) {
form = target;
} else {
if(!target.form) {
return;
}
form = target.form;
}
const method = form.method.toUpperCase();
if(method !== "GET" && method !== "POST") {
return;
}
const url = new URL(form.action);
let path = url.pathname;
const requestInit = {};
const data = new FormData(form);
const params = new URLSearchParams();
for (const [key, value] of data.entries()) {
params.append(key, value);
}
requestInit.headers = {
Accept: "text/html"
};
if(method === "GET") {
path += `?${params.toString()}`;
}
else {
requestInit.method = "POST";
requestInit.body = params;
}
return new Request(
path,
requestInit
);
}
function diffPages(htmlString) {
// Use DOMParser to parse the HTML string
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
diffRange(document, document, doc, doc);
}
function diffRange(oldDocument, oldRoot, newDocument, newRoot, oldEnd, newEnd) {
const oldDocWalker = oldDocument.createTreeWalker(oldRoot);
const newDocWalker = newDocument.createTreeWalker(newRoot);
let oldNode = oldDocWalker.currentNode;
let newNode = newDocWalker.currentNode;
while (oldDocWalker.nextNode() && newDocWalker.nextNode()) {
oldNode = oldDocWalker.currentNode;
newNode = newDocWalker.currentNode;
if (oldNode == oldEnd || newNode == newEnd) {
break;
}
// if the nodes are different, we need to replace the old with the new
// because of the typed view tree, this should never actually happen
if (oldNode.nodeType !== newNode.nodeType) {
oldNode.replaceWith(newNode);
}
// if it's a text node, just update the text with the new text
else if (oldNode.nodeType === Node.TEXT_NODE) {
oldNode.textContent = newNode.textContent;
}
// if it's an element, replace if it's a different tag, or update attributes
else if (oldNode.nodeType === Node.ELEMENT_NODE) {
diffElement(oldNode, newNode);
}
// we use comment "branch marker" nodes to distinguish between different branches in the statically-typed view tree
// if one of these marker is hit, then there are two options
// 1) it's the same branch, and we just keep walking until the end
// 2) it's a different branch, in which case the old can be replaced with the new wholesale
else if (oldNode.nodeType === Node.COMMENT_NODE) {
const oldText = oldNode.textContent;
const newText = newNode.textContent;
if(oldText.startsWith("bo-for")) {
replaceFor(oldDocument, oldDocWalker, newDocument, newDocWalker, oldNode, newNode);
}
else if (oldText.startsWith("bo-item")) {
// skip, this means we're diffing a new item within a For
}
else if(oldText.startsWith("bo") && newText !== oldText) {
replaceBranch(oldDocWalker, newDocWalker, oldNode, newNode);
}
}
}
}
function replaceFor(oldDocument, oldDocWalker, newDocument, newDocWalker, oldNode, newNode) {
oldDocWalker.nextNode();
newDocWalker.nextNode();
const oldRange = new Range();
const newRange = new Range();
let oldBranches = 1;
let newBranches = 1;
const oldKeys = {};
const newKeys = {};
while(oldBranches > 0) {
const c = oldDocWalker.currentNode;
if(c.nodeType === Node.COMMENT_NODE) {
const t = c.textContent;
if(t.startsWith("bo-for")) {
oldBranches += 1;
} else if(t.startsWith("bc-for")) {
oldBranches -= 1;
} else if (t.startsWith("bo-item")) {
const k = t.replace("bo-item-", "");
oldKeys[k] = { open: c, close: null };
} else if (t.startsWith("bc-item")) {
const k = t.replace("bc-item-", "");
oldKeys[k].close = c;
}
}
oldDocWalker.nextNode();
}
while(newBranches > 0) {
const c = newDocWalker.currentNode;
if(c.nodeType === Node.COMMENT_NODE) {
const t = c.textContent;
if(t.startsWith("bo-for")) {
newBranches += 1;
} else if(t.startsWith("bc-for")) {
newBranches -= 1;
} else if (t.startsWith("bo-item")) {
const k = t.replace("bo-item-", "");
newKeys[k] = { open: c, close: null };
} else if (t.startsWith("bc-item")) {
const k = t.replace("bc-item-", "");
newKeys[k].close = c;
}
}
newDocWalker.nextNode();
}
for(const key in oldKeys) {
if(newKeys[key]) {
const oldOne = oldKeys[key];
const newOne = newKeys[key];
const oldRange = new Range();
const newRange = new Range();
// then replace the item in the *new* list with the *old* DOM elements
oldRange.setStartAfter(oldOne.open);
oldRange.setEndBefore(oldOne.close);
newRange.setStartAfter(newOne.open);
newRange.setEndBefore(newOne.close);
const oldContents = oldRange.extractContents();
const newContents = newRange.extractContents();
// patch the *old* DOM elements with the new ones
diffRange(oldDocument, oldContents, newDocument, newContents, oldOne.close, newOne.close);
// then insert the old DOM elements into the new tree
// this means you'll end up with any new attributes or content from the server,
// but with any old DOM state (because they are the old elements)
newRange.insertNode(oldContents);
newOne.open.replaceWith(oldOne.open);
newOne.close.replaceWith(oldOne.close);
}
}
try {
oldRange.setStartAfter(oldNode);
oldRange.setEndBefore(oldDocWalker.currentNode);
newRange.setStartAfter(newNode);
newRange.setEndAfter(newDocWalker.currentNode);
const newContents = newRange.extractContents();
oldRange.deleteContents();
oldRange.insertNode(newContents);
oldNode.replaceWith(newNode);
oldDocWalker.currentNode.replaceWith(newDocWalker.currentNode);
} catch (e) {
console.error(e);
}
}
function replaceBranch(oldDocWalker, newDocWalker, oldNode, newNode) {
oldDocWalker.nextNode();
newDocWalker.nextNode();
const oldRange = new Range();
const newRange = new Range();
let oldBranches = 1;
let newBranches = 1;
while(oldBranches > 0) {
if(oldDocWalker.nextNode()) {
if(oldDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(oldDocWalker.currentNode.textContent.startsWith("bo")) {
oldBranches += 1;
} else if(oldDocWalker.currentNode.textContent.startsWith("bc")) {
oldBranches -= 1;
}
}
}
}
while(newBranches > 0) {
if(newDocWalker.nextNode()) {
if(newDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(newDocWalker.currentNode.textContent.startsWith("bo")) {
newBranches += 1;
} else if(newDocWalker.currentNode.textContent.startsWith("bc")) {
newBranches -= 1;
}
}
}
}
try {
oldRange.setStartAfter(oldNode);
oldRange.setEndBefore(oldDocWalker.currentNode);
newRange.setStartAfter(newNode);
newRange.setEndAfter(newDocWalker.currentNode);
const newContents = newRange.extractContents();
oldRange.deleteContents();
oldRange.insertNode(newContents);
oldNode.replaceWith(newNode);
oldDocWalker.currentNode.replaceWith(newDocWalker.currentNode);
} catch (e) {
console.error(e);
}
}
function diffElement(oldNode, newNode) {
/** @type Element */
const oldEl = oldNode;
/** @type Element */
const newEl = newNode;
if (oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
}
else {
for(const attr of newEl.attributes) {
oldEl.setAttribute(attr.name, attr.value);
}
}
}
for(const island of document.querySelectorAll("leptos-island")) {
island.$$hydrated = true;
}

View File

@@ -50,6 +50,10 @@ pub fn HydrationScripts(
/// Should be `true` to hydrate in `islands` mode.
#[prop(optional)]
islands: bool,
/// Should be `true` to add the “islands router,” which enables limited client-side routing
/// when running in islands mode.
#[prop(optional)]
islands_router: bool,
/// A base url, not including a trailing slash
#[prop(optional, into)]
root: Option<String>,
@@ -98,18 +102,36 @@ pub fn HydrationScripts(
include_str!("./hydration_script.js")
};
let islands_router = islands_router
.then_some(include_str!("./islands_routing.js"))
.unwrap_or_default();
let root = root.unwrap_or_default();
view! {
<link rel="modulepreload" href=format!("{root}/{pkg_path}/{js_file_name}.js") nonce=nonce.clone()/>
<link
rel="preload"
href=format!("{root}/{pkg_path}/{wasm_file_name}.wasm")
r#as="fetch"
r#type="application/wasm"
crossorigin=nonce.clone().unwrap_or_default()
/>
<script type="module" nonce=nonce>
{format!("{script}({root:?}, {pkg_path:?}, {js_file_name:?}, {wasm_file_name:?})")}
</script>
}
use_context::<IslandsRouterNavigation>().is_none().then(|| {
view! {
<link rel="modulepreload" href=format!("{root}/{pkg_path}/{js_file_name}.js") nonce=nonce.clone()/>
<link
rel="preload"
href=format!("{root}/{pkg_path}/{wasm_file_name}.wasm")
r#as="fetch"
r#type="application/wasm"
crossorigin=nonce.clone().unwrap_or_default()
/>
<script type="module" nonce=nonce>
{format!("{script}({root:?}, {pkg_path:?}, {js_file_name:?}, {wasm_file_name:?});{islands_router}")}
</script>
}
})
}
/// If this is provided via context, it means that you are using the islands router and
/// this is a subsequent navigation, made from the client.
///
/// This should be provided automatically by a server integration if it detects that the
/// header `Islands-Router` is present in the request.
///
/// This is used to determine how much of the hydration script to include in the page.
/// If it is present, then the contents of the `<HydrationScripts>` component will not be
/// included, as they only need to be sent to the client once.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IslandsRouterNavigation;

View File

@@ -1,6 +1,6 @@
use std::borrow::Cow;
use tachys::{
html::attribute::Attribute,
html::attribute::{any_attribute::AnyAttribute, Attribute},
hydration::Cursor,
ssr::StreamBuilder,
view::{
@@ -87,6 +87,7 @@ impl<T: Render> Render for View<T> {
impl<T: RenderHtml> RenderHtml for View<T> {
type AsyncOutput = T::AsyncOutput;
type Owned = View<T::Owned>;
const MIN_LENGTH: usize = <T as RenderHtml>::MIN_LENGTH;
@@ -104,6 +105,7 @@ impl<T: RenderHtml> RenderHtml for View<T> {
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
#[cfg(debug_assertions)]
let vm = self.view_marker.to_owned();
@@ -112,8 +114,13 @@ impl<T: RenderHtml> RenderHtml for View<T> {
buf.push_str(&format!("<!--hot-reload|{vm}|open-->"));
}
self.inner
.to_html_with_buf(buf, position, escape, mark_branches);
self.inner.to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
#[cfg(debug_assertions)]
if let Some(vm) = vm.as_ref() {
@@ -127,6 +134,7 @@ impl<T: RenderHtml> RenderHtml for View<T> {
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
@@ -142,6 +150,7 @@ impl<T: RenderHtml> RenderHtml for View<T> {
position,
escape,
mark_branches,
extra_attrs,
);
#[cfg(debug_assertions)]
@@ -157,6 +166,14 @@ impl<T: RenderHtml> RenderHtml for View<T> {
) -> Self::State {
self.inner.hydrate::<FROM_SERVER>(cursor, position)
}
fn into_owned(self) -> Self::Owned {
View {
inner: self.inner.into_owned(),
#[cfg(debug_assertions)]
view_marker: self.view_marker,
}
}
}
impl<T: ToTemplate> ToTemplate for View<T> {

View File

@@ -172,12 +172,10 @@ pub mod prelude {
actions::*, computed::*, effect::*, graph::untrack, owner::*,
signal::*, wrappers::read::*,
};
pub use server_fn::{self, ServerFnError};
pub use server_fn::{self, error::ServerFnError};
pub use tachys::{
reactive_graph::{bind::BindAttribute, node_ref::*, Suspend},
view::{
any_view::AnyView, fragment::Fragment, template::ViewTemplate,
},
view::{fragment::Fragment, template::ViewTemplate},
};
}
pub use export_types::*;

View File

@@ -19,7 +19,7 @@ use slotmap::{DefaultKey, SlotMap};
use std::sync::Arc;
use tachys::{
either::Either,
html::attribute::Attribute,
html::attribute::{any_attribute::AnyAttribute, Attribute},
hydration::Cursor,
reactive_graph::{OwnedView, OwnedViewState},
ssr::StreamBuilder,
@@ -247,6 +247,7 @@ where
// i.e., if this is the child of another Suspense during SSR, don't wait for it: it will handle
// itself
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = Chil::MIN_LENGTH;
@@ -262,9 +263,15 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
self.fallback
.to_html_with_buf(buf, position, escape, mark_branches);
self.fallback.to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
}
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
@@ -273,6 +280,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
@@ -371,6 +379,7 @@ where
position,
escape,
mark_branches,
extra_attrs,
);
}
Some(None) => {
@@ -380,6 +389,7 @@ where
position,
escape,
mark_branches,
extra_attrs,
);
}
None => {
@@ -393,12 +403,14 @@ where
self.fallback,
&mut fallback_position,
mark_branches,
extra_attrs.clone(),
);
buf.push_async_out_of_order_with_nonce(
fut,
position,
mark_branches,
nonce_or_not(),
extra_attrs,
);
} else {
buf.push_async({
@@ -414,6 +426,7 @@ where
&mut position,
escape,
mark_branches,
extra_attrs,
);
builder.finish().take_chunks()
}
@@ -463,6 +476,10 @@ where
}
})
}
fn into_owned(self) -> Self::Owned {
self
}
}
/// A wrapper that prevents [`Suspense`] from waiting for any resource reads that happen inside
@@ -515,6 +532,7 @@ where
T: RenderHtml + 'static,
{
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = T::MIN_LENGTH;
@@ -530,8 +548,15 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
(self.0)().to_html_with_buf(buf, position, escape, mark_branches);
(self.0)().to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
}
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
@@ -540,6 +565,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
@@ -548,6 +574,7 @@ where
position,
escape,
mark_branches,
extra_attrs,
);
}
@@ -558,4 +585,8 @@ where
) -> Self::State {
(self.0)().hydrate::<FROM_SERVER>(cursor, position)
}
fn into_owned(self) -> Self::Owned {
self
}
}

View File

@@ -76,9 +76,9 @@ impl Default for TextProp {
}
impl IntoAttributeValue for TextProp {
type Output = Oco<'static, str>;
type Output = Arc<dyn Fn() -> Oco<'static, str> + Send + Sync>;
fn into_attribute_value(self) -> Self::Output {
self.get()
self.0
}
}

View File

@@ -12,8 +12,9 @@ use typed_builder::TypedBuilder;
/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
/// occur with LeptosOptions
#[derive(Clone, Debug, serde::Deserialize, Default)]
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct ConfFile {
pub leptos_options: LeptosOptions,
}
@@ -24,9 +25,14 @@ pub struct ConfFile {
/// It shares keys with cargo-leptos, to allow for easy interoperability
#[derive(TypedBuilder, Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
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())]
/// The name of the WASM and JS files generated by wasm-bindgen.
///
/// This should match the name that will be output when building your application.
///
/// You can easily set this using `env!("CARGO_CRATE_NAME")`.
#[builder(setter(into))]
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.
@@ -78,6 +84,40 @@ pub struct LeptosOptions {
#[builder(default = default_hash_files())]
#[serde(default = "default_hash_files")]
pub hash_files: bool,
/// The default prefix to use for server functions when generating API routes. Can be
/// overridden for individual functions using `#[server(prefix = "...")]` as usual.
///
/// This is useful to override the default prefix (`/api`) for all server functions without
/// needing to manually specify via `#[server(prefix = "...")]` on every server function.
#[builder(default, setter(strip_option))]
#[serde(default)]
pub server_fn_prefix: Option<String>,
/// Whether to disable appending the server functions' hashes to the end of their API names.
///
/// This is useful when an app's client side needs a stable server API. For example, shipping
/// the CSR WASM binary in a Tauri app. Tauri app releases are dependent on each platform's
/// distribution method (e.g., the Apple App Store or the Google Play Store), which typically
/// are much slower than the frequency at which a website can be updated. In addition, it's
/// common for users to not have the latest app version installed. In these cases, the CSR WASM
/// app would need to be able to continue calling the backend server function API, so the API
/// path needs to be consistent and not have a hash appended.
///
/// Note that the hash suffixes is intended as a way to ensure duplicate API routes are created.
/// Without the hash, server functions will need to have unique names to avoid creating
/// duplicate routes. Axum will throw an error if a duplicate route is added to the router, but
/// Actix will not.
#[builder(default)]
#[serde(default)]
pub disable_server_fn_hash: bool,
/// Include the module path of the server function in the API route. This is an alternative
/// strategy to prevent duplicate server function API routes (the default strategy is to add
/// a hash to the end of the route). Each element of the module path will be separated by a `/`.
/// For example, a server function with a fully qualified name of `parent::child::server_fn`
/// would have an API route of `/api/parent/child/server_fn` (possibly with a
/// different prefix and a hash suffix depending on the values of the other server fn configs).
#[builder(default)]
#[serde(default)]
pub server_fn_mod_path: bool,
}
impl LeptosOptions {
@@ -120,20 +160,14 @@ impl LeptosOptions {
hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?
.into(),
hash_files: env_w_default("LEPTOS_HASH_FILES", "false")?.parse()?,
server_fn_prefix: env_wo_default("SERVER_FN_PREFIX")?,
disable_server_fn_hash: env_wo_default("DISABLE_SERVER_FN_HASH")?
.is_some(),
server_fn_mod_path: env_wo_default("SERVER_FN_MOD_PATH")?.is_some(),
})
}
}
impl Default for LeptosOptions {
fn default() -> Self {
LeptosOptions::builder().build()
}
}
fn default_output_name() -> Arc<str> {
env!("CARGO_CRATE_NAME").replace('-', "_").into()
}
fn default_site_root() -> Arc<str> {
".".into()
}

View File

@@ -51,6 +51,13 @@ trace-component-props = []
actix = ["server_fn_macro/actix"]
axum = ["server_fn_macro/axum"]
generic = ["server_fn_macro/generic"]
# Having an erasure feature rather than normal --cfg erase_components for the proc macro crate is a workaround for this rust issue:
# https://github.com/rust-lang/cargo/issues/4423
# TLDR proc macros will ignore RUSTFLAGS when --target is specified on the cargo command.
# This works around the issue by the non proc-macro crate which does see RUSTFLAGS enabling the replacement feature on the proc-macro crate, which wouldn't.
# This is automatic as long as the leptos crate is depended upon,
# downstream usage should never manually enable this feature.
__internal_erase_components = []
[package.metadata.cargo-all-features]
denylist = ["nightly", "tracing", "trace-component-props"]
@@ -83,9 +90,3 @@ skip_feature_sets = [
[package.metadata.docs.rs]
rustdoc-args = ["--generate-link-to-definition"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(leptos_debuginfo)',
'cfg(erase_components)',
] }

View File

@@ -32,6 +32,8 @@ pub struct Model {
impl Parse for Model {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut item = ItemFn::parse(input)?;
maybe_modify_return_type(&mut item.sig.output);
convert_impl_trait_to_generic(&mut item.sig);
let docs = Docs::new(&item.attrs);
@@ -76,6 +78,39 @@ impl Parse for Model {
}
}
/// Exists to fix nested routes defined in a separate component in erased mode,
/// by replacing the return type with AnyNestedRoute, which is what it'll be, but is required as the return type for compiler inference.
fn maybe_modify_return_type(ret: &mut ReturnType) {
#[cfg(feature = "__internal_erase_components")]
{
if let ReturnType::Type(_, ty) = ret {
if let Type::ImplTrait(TypeImplTrait { bounds, .. }) = ty.as_ref() {
// If one of the bounds is MatchNestedRoutes, we need to replace the return type with AnyNestedRoute:
if bounds.iter().any(|bound| {
if let syn::TypeParamBound::Trait(trait_bound) = bound {
if trait_bound.path.segments.iter().any(
|path_segment| {
path_segment.ident == "MatchNestedRoutes"
},
) {
return true;
}
}
false
}) {
*ty = parse_quote!(
::leptos_router::any_nested_route::AnyNestedRoute
);
}
}
}
}
#[cfg(not(feature = "__internal_erase_components"))]
{
let _ = ret;
}
}
// implemented manually because Vec::drain_filter is nightly only
// follows std recommended parallel
pub fn drain_filter<T>(
@@ -296,9 +331,9 @@ impl ToTokens for Model {
let component = if *is_transparent {
body_expr
} else if cfg!(erase_components) {
} else if cfg!(feature = "__internal_erase_components") {
quote! {
::leptos::prelude::IntoAny::into_any(
::leptos::prelude::IntoMaybeErased::into_maybe_erased(
::leptos::reactive::graph::untrack_with_diagnostics(
move || {
#tracing_guard_expr
@@ -613,7 +648,8 @@ impl Parse for DummyModel {
drain_filter(&mut attrs, |attr| !attr.path().is_ident("doc"));
let vis: Visibility = input.parse()?;
let sig: Signature = input.parse()?;
let mut sig: Signature = input.parse()?;
maybe_modify_return_type(&mut sig.output);
// The body is left untouched, so it will not cause an error
// even if the syntax is invalid.

View File

@@ -281,7 +281,11 @@ pub fn view(tokens: TokenStream) -> TokenStream {
#[proc_macro]
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip_all))]
pub fn template(tokens: TokenStream) -> TokenStream {
view_macro_impl(tokens, true)
if cfg!(feature = "__internal_erase_components") {
view(tokens)
} else {
view_macro_impl(tokens, true)
}
}
fn view_macro_impl(tokens: TokenStream, template: bool) -> TokenStream {
@@ -923,7 +927,7 @@ pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
args.into(),
s.into(),
Some(syn::parse_quote!(::leptos::server_fn)),
"/api",
option_env!("SERVER_FN_PREFIX").unwrap_or("/api"),
None,
None,
) {

View File

@@ -170,8 +170,14 @@ pub(crate) fn component_to_tokens(
.collect::<Vec<_>>();
let spreads = (!(spreads.is_empty())).then(|| {
quote! {
.add_any_attr((#(#spreads,)*).into_attr())
if cfg!(feature = "__internal_erase_components") {
quote! {
.add_any_attr(vec![#(#spreads.into_any_attr(),)*])
}
} else {
quote! {
.add_any_attr((#(#spreads,)*))
}
}
});

View File

@@ -428,6 +428,12 @@ fn element_children_to_tokens(
{ #child }
)
})
} else if cfg!(feature = "__internal_erase_components") {
Some(quote! {
.child(
leptos::tachys::view::iterators::StaticVec::from(vec![#(#children.into_maybe_erased()),*])
)
})
} else if children.len() > 16 {
// implementations of various traits used in routing and rendering are implemented for
// tuples of sizes 0, 1, 2, 3, ... N. N varies but is > 16. The traits are also implemented
@@ -473,6 +479,10 @@ fn fragment_to_tokens(
None
} else if children.len() == 1 {
children.into_iter().next()
} else if cfg!(feature = "__internal_erase_components") {
Some(quote! {
leptos::tachys::view::iterators::StaticVec::from(vec![#(#children.into_maybe_erased()),*])
})
} else if children.len() > 16 {
// implementations of various traits used in routing and rendering are implemented for
// tuples of sizes 0, 1, 2, 3, ... N. N varies but is > 16. The traits are also implemented
@@ -757,10 +767,18 @@ pub(crate) fn element_to_tokens(
}
}
}
Some(quote! {
(#(#attributes,)*)
#(.add_any_attr(#additions))*
})
if cfg!(feature = "__internal_erase_components") {
Some(quote! {
vec![#(#attributes.into_any_attr(),)*]
#(.add_any_attr(#additions))*
})
} else {
Some(quote! {
(#(#attributes,)*)
#(.add_any_attr(#additions))*
})
}
} else {
let tag = name.to_string();
// collect close_tag name to emit semantic information for IDE.

View File

@@ -83,7 +83,7 @@ pub(crate) fn slot_to_tokens(
let value = attr.value().map(|v| {
quote! { #v }
})?;
Some(quote! { (#name, ::leptos::IntoAttribute::into_attribute(#value)) })
Some(quote! { (#name, #value) })
})
.collect::<Vec<_>>();

View File

@@ -1,9 +1,8 @@
#[cfg(not(feature = "ssr"))]
pub mod tests {
use leptos::{
server,
server_fn::{codec, ServerFn, ServerFnError},
server_fn::{codec, Http, ServerFn, ServerFnError},
};
use std::any::TypeId;
@@ -19,8 +18,8 @@ pub mod tests {
"/api/my_server_action"
);
assert_eq!(
TypeId::of::<<MyServerAction as ServerFn>::InputEncoding>(),
TypeId::of::<codec::PostUrl>()
TypeId::of::<<MyServerAction as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::PostUrl, codec::Json>>()
);
}
@@ -32,8 +31,8 @@ pub mod tests {
}
assert_eq!(<FooBar as ServerFn>::PATH, "/foo/bar/my_path");
assert_eq!(
TypeId::of::<<FooBar as ServerFn>::InputEncoding>(),
TypeId::of::<codec::Cbor>()
TypeId::of::<<FooBar as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::Cbor, codec::Cbor>>()
);
}
@@ -45,8 +44,8 @@ pub mod tests {
}
assert_eq!(<FooBar as ServerFn>::PATH, "/foo/bar/my_path");
assert_eq!(
TypeId::of::<<FooBar as ServerFn>::InputEncoding>(),
TypeId::of::<codec::Cbor>()
TypeId::of::<<FooBar as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::Cbor, codec::Cbor>>()
);
}
@@ -58,8 +57,8 @@ pub mod tests {
}
assert_eq!(<FooBar as ServerFn>::PATH, "/api/my_path");
assert_eq!(
TypeId::of::<<FooBar as ServerFn>::InputEncoding>(),
TypeId::of::<codec::PostUrl>()
TypeId::of::<<FooBar as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::PostUrl, codec::Json>>()
);
}
@@ -74,8 +73,8 @@ pub mod tests {
"/api/my_server_action"
);
assert_eq!(
TypeId::of::<<FooBar as ServerFn>::InputEncoding>(),
TypeId::of::<codec::PostUrl>()
TypeId::of::<<FooBar as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::PostUrl, codec::Json>>()
);
}
@@ -91,8 +90,8 @@ pub mod tests {
"/foo/bar/my_server_action"
);
assert_eq!(
TypeId::of::<<MyServerAction as ServerFn>::InputEncoding>(),
TypeId::of::<codec::PostUrl>()
TypeId::of::<<MyServerAction as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::PostUrl, codec::Json>>()
);
}
@@ -108,8 +107,8 @@ pub mod tests {
"/api/my_server_action"
);
assert_eq!(
TypeId::of::<<MyServerAction as ServerFn>::InputEncoding>(),
TypeId::of::<codec::GetUrl>()
TypeId::of::<<MyServerAction as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::GetUrl, codec::Json>>()
);
}
@@ -124,8 +123,8 @@ pub mod tests {
"/api/path/to/my/endpoint"
);
assert_eq!(
TypeId::of::<<MyServerAction as ServerFn>::InputEncoding>(),
TypeId::of::<codec::PostUrl>()
TypeId::of::<<MyServerAction as ServerFn>::Protocol>(),
TypeId::of::<Http<codec::PostUrl, codec::Json>>()
);
}
}

View File

@@ -1,4 +1,4 @@
#[cfg(not(erase_components))]
#[cfg(not(feature = "__internal_erase_components"))]
#[test]
fn ui() {
let t = trybuild::TestCases::new();

View File

@@ -1,6 +1,8 @@
[package]
name = "leptos_server"
version = { workspace = true }
# TODO revert to { workspace = true } before 0.8.0 release
# this is a hack because I missing bumping the hydration_context version number before publishing
version = "0.8.0-alpha2"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"

View File

@@ -3,7 +3,7 @@ use reactive_graph::{
owner::use_context,
traits::DefinedAt,
};
use server_fn::{error::ServerFnErrorSerde, ServerFn, ServerFnError};
use server_fn::{error::FromServerFnError, ServerFn};
use std::{ops::Deref, panic::Location, sync::Arc};
/// An error that can be caused by a server action.
@@ -42,7 +42,7 @@ where
S: ServerFn + 'static,
S::Output: 'static,
{
inner: ArcAction<S, Result<S::Output, ServerFnError<S::Error>>>,
inner: ArcAction<S, Result<S::Output, S::Error>>,
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: &'static Location<'static>,
}
@@ -52,13 +52,14 @@ where
S: ServerFn + Clone + Send + Sync + 'static,
S::Output: Send + Sync + 'static,
S::Error: Send + Sync + 'static,
S::Error: FromServerFnError,
{
/// Creates a new [`ArcAction`] that will call the server function `S` when dispatched.
#[track_caller]
pub fn new() -> Self {
let err = use_context::<ServerActionError>().and_then(|error| {
(error.path() == S::PATH)
.then(|| ServerFnError::<S::Error>::de(error.err()))
.then(|| S::Error::de(error.err()))
.map(Err)
});
Self {
@@ -76,7 +77,7 @@ where
S: ServerFn + 'static,
S::Output: 'static,
{
type Target = ArcAction<S, Result<S::Output, ServerFnError<S::Error>>>;
type Target = ArcAction<S, Result<S::Output, S::Error>>;
fn deref(&self) -> &Self::Target {
&self.inner
@@ -131,7 +132,7 @@ where
S: ServerFn + 'static,
S::Output: 'static,
{
inner: Action<S, Result<S::Output, ServerFnError<S::Error>>>,
inner: Action<S, Result<S::Output, S::Error>>,
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: &'static Location<'static>,
}
@@ -146,7 +147,7 @@ where
pub fn new() -> Self {
let err = use_context::<ServerActionError>().and_then(|error| {
(error.path() == S::PATH)
.then(|| ServerFnError::<S::Error>::de(error.err()))
.then(|| S::Error::de(error.err()))
.map(Err)
});
Self {
@@ -182,15 +183,14 @@ where
S::Output: Send + Sync + 'static,
S::Error: Send + Sync + 'static,
{
type Target = Action<S, Result<S::Output, ServerFnError<S::Error>>>;
type Target = Action<S, Result<S::Output, S::Error>>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<S> From<ServerAction<S>>
for Action<S, Result<S::Output, ServerFnError<S::Error>>>
impl<S> From<ServerAction<S>> for Action<S, Result<S::Output, S::Error>>
where
S: ServerFn + 'static,
S::Output: 'static,

View File

@@ -79,7 +79,7 @@ mod view_implementations {
use reactive_graph::traits::Read;
use std::future::Future;
use tachys::{
html::attribute::Attribute,
html::attribute::{any_attribute::AnyAttribute, Attribute},
hydration::Cursor,
reactive_graph::{RenderEffectState, Suspend, SuspendState},
ssr::StreamBuilder,
@@ -135,6 +135,7 @@ mod view_implementations {
Ser: Send + 'static,
{
type AsyncOutput = Option<T>;
type Owned = Self;
const MIN_LENGTH: usize = 0;
@@ -152,12 +153,14 @@ mod view_implementations {
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
(move || Suspend::new(async move { self.await })).to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
}
@@ -167,6 +170,7 @@ mod view_implementations {
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
@@ -176,6 +180,7 @@ mod view_implementations {
position,
escape,
mark_branches,
extra_attrs,
);
}
@@ -187,5 +192,9 @@ mod view_implementations {
(move || Suspend::new(async move { self.await }))
.hydrate::<FROM_SERVER>(cursor, position)
}
fn into_owned(self) -> Self::Owned {
self
}
}
}

View File

@@ -172,6 +172,12 @@ where
fn try_read_untracked(&self) -> Option<Self::Value> {
if let Some(mut notifier) = use_context::<LocalResourceNotifier>() {
notifier.notify();
} else if cfg!(feature = "ssr") {
panic!(
"Reading from a LocalResource outside Suspense in `ssr` mode \
will cause the response to hang, because LocalResources are \
always pending on the server."
);
}
self.data.try_read_untracked()
}
@@ -358,6 +364,12 @@ where
fn try_read_untracked(&self) -> Option<Self::Value> {
if let Some(mut notifier) = use_context::<LocalResourceNotifier>() {
notifier.notify();
} else if cfg!(feature = "ssr") {
panic!(
"Reading from a LocalResource outside Suspense in `ssr` mode \
will cause the response to hang, because LocalResources are \
always pending on the server."
);
}
self.data.try_read_untracked()
}

View File

@@ -2,7 +2,7 @@ use reactive_graph::{
actions::{ArcMultiAction, MultiAction},
traits::DefinedAt,
};
use server_fn::{ServerFn, ServerFnError};
use server_fn::ServerFn;
use std::{ops::Deref, panic::Location};
/// An [`ArcMultiAction`] that can be used to call a server function.
@@ -11,7 +11,7 @@ where
S: ServerFn + 'static,
S::Output: 'static,
{
inner: ArcMultiAction<S, Result<S::Output, ServerFnError<S::Error>>>,
inner: ArcMultiAction<S, Result<S::Output, S::Error>>,
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: &'static Location<'static>,
}
@@ -40,7 +40,7 @@ where
S: ServerFn + 'static,
S::Output: 'static,
{
type Target = ArcMultiAction<S, Result<S::Output, ServerFnError<S::Error>>>;
type Target = ArcMultiAction<S, Result<S::Output, S::Error>>;
fn deref(&self) -> &Self::Target {
&self.inner
@@ -95,13 +95,13 @@ where
S: ServerFn + 'static,
S::Output: 'static,
{
inner: MultiAction<S, Result<S::Output, ServerFnError<S::Error>>>,
inner: MultiAction<S, Result<S::Output, S::Error>>,
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: &'static Location<'static>,
}
impl<S> From<ServerMultiAction<S>>
for MultiAction<S, Result<S::Output, ServerFnError<S::Error>>>
for MultiAction<S, Result<S::Output, S::Error>>
where
S: ServerFn + 'static,
S::Output: 'static,
@@ -152,7 +152,7 @@ where
S::Output: 'static,
S::Error: 'static,
{
type Target = MultiAction<S, Result<S::Output, ServerFnError<S::Error>>>;
type Target = MultiAction<S, Result<S::Output, S::Error>>;
fn deref(&self) -> &Self::Target {
&self.inner

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.7.7"
version = "0.8.0-alpha"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"

View File

@@ -1,6 +1,6 @@
use crate::ServerMetaContext;
use leptos::{
attr::NextAttribute,
attr::{any_attribute::AnyAttribute, NextAttribute},
component, html,
reactive::owner::use_context,
tachys::{
@@ -103,6 +103,7 @@ where
At: Attribute,
{
type AsyncOutput = BodyView<At::AsyncOutput>;
type Owned = BodyView<At::CloneableOwned>;
const MIN_LENGTH: usize = At::MIN_LENGTH;
@@ -122,10 +123,14 @@ where
_position: &mut Position,
_escape: bool,
_mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
if let Some(meta) = use_context::<ServerMetaContext>() {
let mut buf = String::new();
_ = html::attributes_to_html(self.attributes, &mut buf);
_ = html::attributes_to_html(
(self.attributes, extra_attrs),
&mut buf,
);
if !buf.is_empty() {
_ = meta.body.send(buf);
}
@@ -142,6 +147,12 @@ where
BodyViewState { attributes }
}
fn into_owned(self) -> Self::Owned {
BodyView {
attributes: self.attributes.into_cloneable_owned(),
}
}
}
impl<At> Mountable for BodyViewState<At>
@@ -160,4 +171,11 @@ where
fn insert_before_this(&self, _child: &mut dyn Mountable) -> bool {
false
}
fn elements(&self) -> Vec<leptos::tachys::renderer::types::Element> {
vec![document()
.body()
.expect("there to be a <body> element")
.into()]
}
}

View File

@@ -1,6 +1,6 @@
use crate::ServerMetaContext;
use leptos::{
attr::NextAttribute,
attr::{any_attribute::AnyAttribute, NextAttribute},
component, html,
reactive::owner::use_context,
tachys::{
@@ -103,6 +103,7 @@ where
At: Attribute,
{
type AsyncOutput = HtmlView<At::AsyncOutput>;
type Owned = HtmlView<At::CloneableOwned>;
const MIN_LENGTH: usize = At::MIN_LENGTH;
@@ -122,10 +123,14 @@ where
_position: &mut Position,
_escape: bool,
_mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
if let Some(meta) = use_context::<ServerMetaContext>() {
let mut buf = String::new();
_ = html::attributes_to_html(self.attributes, &mut buf);
_ = html::attributes_to_html(
(self.attributes, extra_attrs),
&mut buf,
);
if !buf.is_empty() {
_ = meta.html.send(buf);
}
@@ -145,6 +150,12 @@ where
HtmlViewState { attributes }
}
fn into_owned(self) -> Self::Owned {
HtmlView {
attributes: self.attributes.into_cloneable_owned(),
}
}
}
impl<At> Mountable for HtmlViewState<At>
@@ -165,4 +176,10 @@ where
fn insert_before_this(&self, _child: &mut dyn Mountable) -> bool {
false
}
fn elements(&self) -> Vec<leptos::tachys::renderer::types::Element> {
vec![document()
.document_element()
.expect("there to be a <html> element")]
}
}

View File

@@ -44,7 +44,7 @@
use futures::{Stream, StreamExt};
use leptos::{
attr::NextAttribute,
attr::{any_attribute::AnyAttribute, NextAttribute},
component,
logging::debug_warn,
oco::Oco,
@@ -405,6 +405,7 @@ where
Ch: RenderHtml + Send,
{
type AsyncOutput = Self;
type Owned = RegisteredMetaTag<E, At::CloneableOwned, Ch::Owned>;
const MIN_LENGTH: usize = 0;
@@ -422,6 +423,7 @@ where
_position: &mut Position,
_escape: bool,
_mark_branches: bool,
_extra_attrs: Vec<AnyAttribute>,
) {
// meta tags are rendered into the buffer stored into the context
// the value has already been taken out, when we're on the server
@@ -433,6 +435,7 @@ where
&mut Position::NextChild,
false,
false,
vec![],
);
_ = cx.elements.send(buf); // fails only if the receiver is already dropped
} else {
@@ -464,6 +467,12 @@ where
);
RegisteredMetaTagState { state }
}
fn into_owned(self) -> Self::Owned {
RegisteredMetaTag {
el: self.el.into_owned(),
}
}
}
impl<E, At, Ch> Mountable for RegisteredMetaTagState<E, At, Ch>
@@ -496,6 +505,10 @@ where
// we intended!
false
}
fn elements(&self) -> Vec<leptos::tachys::renderer::types::Element> {
self.state.elements()
}
}
/// During server rendering, inserts the meta tags that have been generated by the other components
@@ -537,6 +550,7 @@ impl AddAnyAttr for MetaTagsView {
impl RenderHtml for MetaTagsView {
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = 0;
@@ -552,6 +566,7 @@ impl RenderHtml for MetaTagsView {
_position: &mut Position,
_escape: bool,
_mark_branches: bool,
_extra_attrs: Vec<AnyAttribute>,
) {
buf.push_str("<!--HEAD-->");
}
@@ -562,6 +577,10 @@ impl RenderHtml for MetaTagsView {
_position: &PositionState,
) -> Self::State {
}
fn into_owned(self) -> Self::Owned {
self
}
}
pub(crate) trait OrDefaultNonce {

View File

@@ -36,6 +36,10 @@ pub fn Stylesheet(
}
/// Injects an [`HTMLLinkElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement) into the document head that loads a `cargo-leptos`-hashed stylesheet.
///
/// This should only be used in the applications server-side `shell` function, as
/// [`LeptosOptions`] is not available in the browser. Unlike other `leptos_meta` components, it
/// will render the `<link>` it creates exactly where it is called.
#[component]
pub fn HashedStylesheet(
/// Leptos options
@@ -74,11 +78,9 @@ pub fn HashedStylesheet(
css_file_name.push_str(".css");
let pkg_path = &options.site_pkg_dir;
let root = root.unwrap_or_default();
// TODO additional attributes
register(
link()
.id(id)
.rel("stylesheet")
.href(format!("{root}/{pkg_path}/{css_file_name}")),
)
link()
.id(id)
.rel("stylesheet")
.href(format!("{root}/{pkg_path}/{css_file_name}"))
}

View File

@@ -1,6 +1,6 @@
use crate::{use_head, MetaContext, ServerMetaContext};
use leptos::{
attr::Attribute,
attr::{any_attribute::AnyAttribute, Attribute},
component,
oco::Oco,
reactive::{
@@ -234,6 +234,7 @@ impl AddAnyAttr for TitleView {
impl RenderHtml for TitleView {
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = 0;
@@ -249,6 +250,7 @@ impl RenderHtml for TitleView {
_position: &mut Position,
_escape: bool,
_mark_branches: bool,
_extra_attrs: Vec<AnyAttribute>,
) {
// meta tags are rendered into the buffer stored into the context
// the value has already been taken out, when we're on the server
@@ -282,6 +284,10 @@ impl RenderHtml for TitleView {
});
TitleViewState { effect }
}
fn into_owned(self) -> Self::Owned {
self
}
}
impl Mountable for TitleViewState {
@@ -299,4 +305,8 @@ impl Mountable for TitleViewState {
fn insert_before_this(&self, _child: &mut dyn Mountable) -> bool {
false
}
fn elements(&self) -> Vec<leptos::tachys::renderer::types::Element> {
vec![]
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "reactive_graph"
version = "0.1.7"
version = "0.2.0-alpha2"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -961,11 +961,10 @@ where
}
}
impl<I, O, S> Action<I, O, S>
impl<I, O> Action<I, O>
where
I: Send + Sync + 'static,
O: Send + Sync + 'static,
S: Storage<ArcAction<I, O>>,
{
/// Creates a new action, which does not require the action itself to be `Send`, but will run
/// it on the same thread it was created on.
@@ -1006,6 +1005,56 @@ where
}
}
impl<I, O> Action<I, O, LocalStorage>
where
I: 'static,
O: 'static,
{
/// Creates a new action, which neither requires the action itself nor the
/// value it returns to be `Send`. If this action is accessed from outside the
/// thread on which it was created, it panics.
///
/// This combines the features of [`Action::new_local`] and [`Action::new_unsync`].
#[track_caller]
pub fn new_unsync_local<F, Fu>(action_fn: F) -> Self
where
F: Fn(&I) -> Fu + 'static,
Fu: Future<Output = O> + 'static,
{
Self {
inner: ArenaItem::new_with_storage(ArcAction::new_unsync(
action_fn,
)),
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: Location::caller(),
}
}
/// Creates a new action, which neither requires the action itself nor the
/// value it returns to be `Send`, and provides it with an initial value.
/// If this action is accessed from outside the thread on which it was created, it panics.
///
/// This combines the features of [`Action::new_local_with_value`] and
/// [`Action::new_unsync_with_value`].
#[track_caller]
pub fn new_unsync_local_with_value<F, Fu>(
value: Option<O>,
action_fn: F,
) -> Self
where
F: Fn(&I) -> Fu + 'static,
Fu: Future<Output = O> + 'static,
{
Self {
inner: ArenaItem::new_with_storage(
ArcAction::new_unsync_with_value(value, action_fn),
),
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: Location::caller(),
}
}
}
impl<I, O, S> DefinedAt for Action<I, O, S> {
fn defined_at(&self) -> Option<&'static Location<'static>> {
#[cfg(any(debug_assertions, leptos_debuginfo))]

View File

@@ -8,7 +8,7 @@ use crate::{
use or_poisoned::OrPoisoned;
use std::{
fmt::Debug,
sync::{Arc, RwLock},
sync::{Arc, RwLock, RwLockWriteGuard},
};
pub struct MemoInner<T, S>
@@ -72,17 +72,21 @@ where
}
fn mark_check(&self) {
{
let mut lock = self.reactivity.write().or_poisoned();
if lock.state != ReactiveNodeState::Dirty {
lock.state = ReactiveNodeState::Check;
/// codegen optimisation:
fn inner(reactivity: &RwLock<MemoInnerReactivity>) {
{
let mut lock = reactivity.write().or_poisoned();
if lock.state != ReactiveNodeState::Dirty {
lock.state = ReactiveNodeState::Check;
}
}
for sub in
(&reactivity.read().or_poisoned().subscribers).into_iter()
{
sub.mark_check();
}
}
for sub in
(&self.reactivity.read().or_poisoned().subscribers).into_iter()
{
sub.mark_check();
}
inner(&self.reactivity);
}
fn mark_subscribers_check(&self) {
@@ -93,64 +97,87 @@ where
}
fn update_if_necessary(&self) -> bool {
let (state, sources) = {
let inner = self.reactivity.read().or_poisoned();
(inner.state, inner.sources.clone())
};
/// codegen optimisation:
fn needs_update(reactivity: &RwLock<MemoInnerReactivity>) -> bool {
let (state, sources) = {
let inner = reactivity.read().or_poisoned();
(inner.state, inner.sources.clone())
};
match state {
ReactiveNodeState::Clean => false,
ReactiveNodeState::Dirty => true,
ReactiveNodeState::Check => {
(&sources).into_iter().any(|source| {
source.update_if_necessary()
|| reactivity.read().or_poisoned().state
== ReactiveNodeState::Dirty
})
}
}
}
let needs_update = match state {
ReactiveNodeState::Clean => false,
ReactiveNodeState::Dirty => true,
ReactiveNodeState::Check => (&sources).into_iter().any(|source| {
source.update_if_necessary()
|| self.reactivity.read().or_poisoned().state
== ReactiveNodeState::Dirty
}),
};
if needs_update {
let fun = self.fun.clone();
let owner = self.owner.clone();
if needs_update(&self.reactivity) {
// No deadlock risk, because we only hold the value lock.
let value = self.value.write().or_poisoned().take();
let any_subscriber =
{ self.reactivity.read().or_poisoned().any_subscriber.clone() };
any_subscriber.clear_sources(&any_subscriber);
let (new_value, changed) = owner.with_cleanup(|| {
/// codegen optimisation:
fn inner_1(
reactivity: &RwLock<MemoInnerReactivity>,
) -> AnySubscriber {
let any_subscriber =
reactivity.read().or_poisoned().any_subscriber.clone();
any_subscriber.clear_sources(&any_subscriber);
any_subscriber
.with_observer(|| fun(value.map(StorageAccess::into_taken)))
}
let any_subscriber = inner_1(&self.reactivity);
let (new_value, changed) = self.owner.with_cleanup(|| {
any_subscriber.with_observer(|| {
(self.fun)(value.map(StorageAccess::into_taken))
})
});
// Two locks are aquired, so order matters.
let mut reactivity_lock = self.reactivity.write().or_poisoned();
let reactivity_lock = self.reactivity.write().or_poisoned();
{
// Safety: Can block endlessly if the user is has a ReadGuard on the value
let mut value_lock = self.value.write().or_poisoned();
*value_lock = Some(S::wrap(new_value));
}
reactivity_lock.state = ReactiveNodeState::Clean;
if changed {
let subs = reactivity_lock.subscribers.clone();
drop(reactivity_lock);
for sub in subs {
// don't trigger reruns of effects/memos
// basically: if one of the observers has triggered this memo to
// run, it doesn't need to be re-triggered because of this change
if !Observer::is(&sub) {
sub.mark_dirty();
/// codegen optimisation:
fn inner_2(
changed: bool,
mut reactivity_lock: RwLockWriteGuard<'_, MemoInnerReactivity>,
) {
reactivity_lock.state = ReactiveNodeState::Clean;
if changed {
let subs = reactivity_lock.subscribers.clone();
drop(reactivity_lock);
for sub in subs {
// don't trigger reruns of effects/memos
// basically: if one of the observers has triggered this memo to
// run, it doesn't need to be re-triggered because of this change
if !Observer::is(&sub) {
sub.mark_dirty();
}
}
} else {
drop(reactivity_lock);
}
} else {
drop(reactivity_lock);
}
inner_2(changed, reactivity_lock);
changed
} else {
let mut lock = self.reactivity.write().or_poisoned();
lock.state = ReactiveNodeState::Clean;
false
/// codegen optimisation:
fn inner(reactivity: &RwLock<MemoInnerReactivity>) -> bool {
let mut lock = reactivity.write().or_poisoned();
lock.state = ReactiveNodeState::Clean;
false
}
inner(&self.reactivity)
}
}
}

View File

@@ -7,7 +7,6 @@ use crate::{
},
owner::Owner,
};
use any_spawner::Executor;
use futures::StreamExt;
use or_poisoned::OrPoisoned;
use std::{
@@ -54,7 +53,7 @@ where
{
/// Creates a new render effect, which immediately runs `fun`.
pub fn new(fun: impl FnMut(Option<T>) -> T + 'static) -> Self {
Self::new_with_value(fun, None)
Self::new_with_value_erased(Box::new(fun), None)
}
/// Creates a new render effect with an initial value.
@@ -62,59 +61,70 @@ where
fun: impl FnMut(Option<T>) -> T + 'static,
initial_value: Option<T>,
) -> Self {
fn erased<T>(
mut fun: Box<dyn FnMut(Option<T>) -> T + 'static>,
initial_value: Option<T>,
) -> RenderEffect<T> {
let (observer, mut rx) = channel();
let value = Arc::new(RwLock::new(None::<T>));
Self::new_with_value_erased(Box::new(fun), initial_value)
}
fn new_with_value_erased(
mut fun: Box<dyn FnMut(Option<T>) -> T + 'static>,
initial_value: Option<T>,
) -> Self {
// codegen optimisation:
fn prep() -> (Owner, Arc<RwLock<EffectInner>>, crate::channel::Receiver)
{
let (observer, rx) = channel();
let owner = Owner::new();
let inner = Arc::new(RwLock::new(EffectInner {
dirty: false,
observer,
sources: SourceSet::new(),
}));
let initial_value = cfg!(feature = "effects").then(|| {
owner.with(|| {
inner
.to_any_subscriber()
.with_observer(|| fun(initial_value))
})
});
*value.write().or_poisoned() = initial_value;
if cfg!(feature = "effects") {
Executor::spawn_local({
let value = Arc::clone(&value);
let subscriber = inner.to_any_subscriber();
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& subscriber.with_observer(|| {
subscriber.update_if_necessary()
})
{
subscriber.clear_sources(&subscriber);
let old_value = mem::take(
&mut *value.write().or_poisoned(),
);
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun(old_value))
});
*value.write().or_poisoned() = Some(new_value);
}
}
}
});
}
RenderEffect { value, inner }
(owner, inner, rx)
}
erased(Box::new(fun), initial_value)
let (owner, inner, mut rx) = prep();
let value = Arc::new(RwLock::new(None::<T>));
#[cfg(not(feature = "effects"))]
{
let _ = initial_value;
let _ = owner;
let _ = &mut rx;
let _ = &mut fun;
}
#[cfg(feature = "effects")]
{
let subscriber = inner.to_any_subscriber();
*value.write().or_poisoned() = Some(
owner.with(|| subscriber.with_observer(|| fun(initial_value))),
);
any_spawner::Executor::spawn_local({
let value = Arc::clone(&value);
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& subscriber.with_observer(|| {
subscriber.update_if_necessary()
})
{
subscriber.clear_sources(&subscriber);
let old_value =
mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun(old_value))
});
*value.write().or_poisoned() = Some(new_value);
}
}
}
});
}
RenderEffect { value, inner }
}
/// Mutably accesses the current value.

View File

@@ -207,15 +207,26 @@ impl Owner {
/// Runs the given function with this as the current `Owner`.
pub fn with<T>(&self, fun: impl FnOnce() -> T) -> T {
let prev = {
OWNER.with(|o| Option::replace(&mut *o.borrow_mut(), self.clone()))
};
#[cfg(feature = "sandboxed-arenas")]
Arena::set(&self.inner.read().or_poisoned().arena);
// codegen optimisation:
fn inner_1(self_: &Owner) -> Option<Owner> {
let prev =
{ OWNER.with(|o| (*o.borrow_mut()).replace(self_.clone())) };
#[cfg(feature = "sandboxed-arenas")]
Arena::set(&self_.inner.read().or_poisoned().arena);
prev
}
let prev = inner_1(self);
let val = fun();
OWNER.with(|o| {
*o.borrow_mut() = prev;
});
// monomorphisation optimisation:
fn inner_2(prev: Option<Owner>) {
OWNER.with(|o| {
*o.borrow_mut() = prev;
});
}
inner_2(prev);
val
}

View File

@@ -1,6 +1,6 @@
[package]
name = "reactive_stores"
version = "0.1.7"
version = "0.2.0-alpha"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -100,9 +100,6 @@ where
let mut full_path = self.path().into_iter().collect::<StorePath>();
full_path.pop();
// build a list of triggers, starting with the full path to this node and ending with the root
// this will mean that the root is the final item, and this path is first
let mut triggers = Vec::with_capacity(full_path.len());
triggers.push(trigger.this.clone());
loop {
@@ -113,17 +110,6 @@ where
}
full_path.pop();
}
// when the WriteGuard is dropped, each trigger will be notified, in order
// reversing the list will cause the triggers to be notified starting from the root,
// then to each child down to this one
//
// notifying from the root down is important for things like OptionStoreExt::map()/unwrap(),
// where it's really important that any effects that subscribe to .is_some() run before effects
// that subscribe to the inner value, so that the inner effect can be canceled if the outer switches to `None`
// (see https://github.com/leptos-rs/leptos/issues/3704)
triggers.reverse();
let guard = WriteGuard::new(triggers, parent);
Some(MappedMut::new(guard, self.read, self.write))

View File

@@ -1,6 +1,6 @@
[package]
name = "reactive_stores_macro"
version = "0.1.7"
version = "0.2.0-alpha"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_router"
version = "0.7.7"
version = "0.8.0-alpha"
authors = ["Greg Johnston", "Ben Wishovich"]
license = "MIT"
readme = "../README.md"
@@ -66,3 +66,9 @@ rustdoc-args = ["--generate-link-to-definition"]
[package.metadata.cargo-all-features]
denylist = ["tracing"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(leptos_debuginfo)',
'cfg(erase_components)',
] }

View File

@@ -11,7 +11,8 @@ use crate::{
navigate::NavigateOptions,
nested_router::NestedRoutesView,
resolve_path::resolve_path,
ChooseView, MatchNestedRoutes, NestedRoute, RouteDefs, SsrMode,
ChooseView, MatchNestedRoutes, NestedRoute, PossibleRouteMatch, RouteDefs,
SsrMode,
};
use either_of::EitherOf3;
use leptos::{children, prelude::*};
@@ -28,7 +29,6 @@ use std::{
sync::Arc,
time::Duration,
};
use tachys::view::any_view::AnyView;
/// A wrapper that allows passing route definitions as children to a component like [`Routes`],
/// [`FlatRoutes`], [`ParentRoute`], or [`ProtectedParentRoute`].
@@ -344,11 +344,14 @@ pub fn Route<Segments, View>(
/// Defaults to out-of-order streaming.
#[prop(optional)]
ssr: SsrMode,
) -> NestedRoute<Segments, (), (), View>
) -> <NestedRoute<Segments, (), (), View> as IntoMaybeErased>::Output
where
View: ChooseView,
View: ChooseView + Clone + 'static,
Segments: PossibleRouteMatch + Clone + Send + 'static,
{
NestedRoute::new(path, view).ssr_mode(ssr)
NestedRoute::new(path, view)
.ssr_mode(ssr)
.into_maybe_erased()
}
/// Describes a portion of the nested layout of the app, specifying the route it should match
@@ -368,146 +371,186 @@ pub fn ParentRoute<Segments, View, Children>(
/// Defaults to out-of-order streaming.
#[prop(optional)]
ssr: SsrMode,
) -> NestedRoute<Segments, Children, (), View>
) -> <NestedRoute<Segments, Children, (), View> as IntoMaybeErased>::Output
where
View: ChooseView,
View: ChooseView + Clone + 'static,
Children: MatchNestedRoutes + Send + Clone + 'static,
Segments: PossibleRouteMatch + Clone + Send + 'static,
{
let children = children.into_inner();
NestedRoute::new(path, view).ssr_mode(ssr).child(children)
NestedRoute::new(path, view)
.ssr_mode(ssr)
.child(children)
.into_maybe_erased()
}
/// Describes a route that is guarded by a certain condition. This works the same way as
/// [`<Route/>`], except that if the `condition` function evaluates to `Some(false)`, it
/// redirects to `redirect_path` instead of displaying its `view`.
#[component(transparent)]
pub fn ProtectedRoute<Segments, ViewFn, View, C, PathFn, P>(
/// The path fragment that this route should match. This can be created using the
/// [`path`](crate::path) macro, or path segments ([`StaticSegment`](crate::StaticSegment),
/// [`ParamSegment`](crate::ParamSegment), [`WildcardSegment`](crate::WildcardSegment), and
/// [`OptionalParamSegment`](crate::OptionalParamSegment)).
path: Segments,
/// The view for this route.
view: ViewFn,
/// A function that returns `Option<bool>`, where `Some(true)` means that the user can access
/// the page, `Some(false)` means the user cannot access the page, and `None` means this
/// information is still loading.
condition: C,
/// The path that will be redirected to if the condition is `Some(false)`.
redirect_path: PathFn,
/// Will be displayed while the condition is pending. By default this is the empty view.
#[prop(optional, into)]
fallback: children::ViewFn,
/// The mode that this route prefers during server-side rendering.
/// Defaults to out-of-order streaming.
#[prop(optional)]
ssr: SsrMode,
) -> NestedRoute<Segments, (), (), impl Fn() -> AnyView + Send + Clone>
where
ViewFn: Fn() -> View + Send + Clone + 'static,
View: IntoView + 'static,
C: Fn() -> Option<bool> + Send + Clone + 'static,
PathFn: Fn() -> P + Send + Clone + 'static,
P: Display + 'static,
{
let fallback = move || fallback.run();
let view = move || {
let condition = condition.clone();
let redirect_path = redirect_path.clone();
let view = view.clone();
let fallback = fallback.clone();
(view! {
<Transition fallback=fallback.clone()>
{move || {
let condition = condition();
let view = view.clone();
let redirect_path = redirect_path.clone();
let fallback = fallback.clone();
Unsuspend::new(move || match condition {
Some(true) => EitherOf3::A(view()),
#[allow(clippy::unit_arg)]
Some(false) => {
EitherOf3::B(view! { <Redirect path=redirect_path()/> }.into_inner())
}
None => EitherOf3::C(fallback()),
})
}}
</Transition>
})
.into_any()
};
NestedRoute::new(path, view).ssr_mode(ssr)
}
#[component(transparent)]
pub fn ProtectedParentRoute<Segments, ViewFn, View, C, PathFn, P, Children>(
/// The path fragment that this route should match. This can be created using the
/// [`path`](crate::path) macro, or path segments ([`StaticSegment`](crate::StaticSegment),
/// [`ParamSegment`](crate::ParamSegment), [`WildcardSegment`](crate::WildcardSegment), and
/// [`OptionalParamSegment`](crate::OptionalParamSegment)).
path: Segments,
/// The view for this route.
view: ViewFn,
/// A function that returns `Option<bool>`, where `Some(true)` means that the user can access
/// the page, `Some(false)` means the user cannot access the page, and `None` means this
/// information is still loading.
condition: C,
/// Will be displayed while the condition is pending. By default this is the empty view.
#[prop(optional, into)]
fallback: children::ViewFn,
/// The path that will be redirected to if the condition is `Some(false)`.
redirect_path: PathFn,
/// Nested child routes.
children: RouteChildren<Children>,
/// The mode that this route prefers during server-side rendering.
/// Defaults to out-of-order streaming.
#[prop(optional)]
ssr: SsrMode,
) -> NestedRoute<Segments, Children, (), impl Fn() -> AnyView + Send + Clone>
where
ViewFn: Fn() -> View + Send + Clone + 'static,
View: IntoView + 'static,
C: Fn() -> Option<bool> + Send + Clone + 'static,
PathFn: Fn() -> P + Send + Clone + 'static,
P: Display + 'static,
{
let fallback = move || fallback.run();
let children = children.into_inner();
let view = move || {
let condition = condition.clone();
let redirect_path = redirect_path.clone();
let fallback = fallback.clone();
let view = view.clone();
let owner = Owner::current().unwrap();
let view = {
let fallback = fallback.clone();
move || {
let condition = condition();
/// With the `impl Fn` in the return signature, IntoMaybeErased::Output isn't accepted by the compiler, so changing return type depending on the erasure flag.
macro_rules! define_protected_route {
($ret:ty) => {
/// Describes a route that is guarded by a certain condition. This works the same way as
/// [`<Route/>`], except that if the `condition` function evaluates to `Some(false)`, it
/// redirects to `redirect_path` instead of displaying its `view`.
#[component(transparent)]
pub fn ProtectedRoute<Segments, ViewFn, View, C, PathFn, P>(
/// The path fragment that this route should match. This can be created using the
/// [`path`](crate::path) macro, or path segments ([`StaticSegment`](crate::StaticSegment),
/// [`ParamSegment`](crate::ParamSegment), [`WildcardSegment`](crate::WildcardSegment), and
/// [`OptionalParamSegment`](crate::OptionalParamSegment)).
path: Segments,
/// The view for this route.
view: ViewFn,
/// A function that returns `Option<bool>`, where `Some(true)` means that the user can access
/// the page, `Some(false)` means the user cannot access the page, and `None` means this
/// information is still loading.
condition: C,
/// The path that will be redirected to if the condition is `Some(false)`.
redirect_path: PathFn,
/// Will be displayed while the condition is pending. By default this is the empty view.
#[prop(optional, into)]
fallback: children::ViewFn,
/// The mode that this route prefers during server-side rendering.
/// Defaults to out-of-order streaming.
#[prop(optional)]
ssr: SsrMode,
) -> $ret
where
Segments: PossibleRouteMatch + Clone + Send + 'static,
ViewFn: Fn() -> View + Send + Clone + 'static,
View: IntoView + 'static,
C: Fn() -> Option<bool> + Send + Clone + 'static,
PathFn: Fn() -> P + Send + Clone + 'static,
P: Display + 'static,
{
let fallback = move || fallback.run();
let view = move || {
let condition = condition.clone();
let redirect_path = redirect_path.clone();
let view = view.clone();
let fallback = fallback.clone();
(view! {
<Transition fallback=fallback.clone()>
{move || {
let condition = condition();
let view = view.clone();
let redirect_path = redirect_path.clone();
let fallback = fallback.clone();
Unsuspend::new(move || match condition {
Some(true) => EitherOf3::A(view()),
#[allow(clippy::unit_arg)]
Some(false) => {
EitherOf3::B(view! { <Redirect path=redirect_path()/> }.into_inner())
}
None => EitherOf3::C(fallback()),
})
}}
</Transition>
})
.into_any()
};
NestedRoute::new(path, view).ssr_mode(ssr).into_maybe_erased()
}
};
}
#[cfg(erase_components)]
define_protected_route!(crate::any_nested_route::AnyNestedRoute);
#[cfg(not(erase_components))]
define_protected_route!(NestedRoute<Segments, (), (), impl Fn() -> AnyView + Send + Clone>);
/// With the `impl Fn` in the return signature, IntoMaybeErased::Output isn't accepted by the compiler, so changing return type depending on the erasure flag.
macro_rules! define_protected_parent_route {
($ret:ty) => {
#[component(transparent)]
pub fn ProtectedParentRoute<
Segments,
ViewFn,
View,
C,
PathFn,
P,
Children,
>(
/// The path fragment that this route should match. This can be created using the
/// [`path`](crate::path) macro, or path segments ([`StaticSegment`](crate::StaticSegment),
/// [`ParamSegment`](crate::ParamSegment), [`WildcardSegment`](crate::WildcardSegment), and
/// [`OptionalParamSegment`](crate::OptionalParamSegment)).
path: Segments,
/// The view for this route.
view: ViewFn,
/// A function that returns `Option<bool>`, where `Some(true)` means that the user can access
/// the page, `Some(false)` means the user cannot access the page, and `None` means this
/// information is still loading.
condition: C,
/// Will be displayed while the condition is pending. By default this is the empty view.
#[prop(optional, into)]
fallback: children::ViewFn,
/// The path that will be redirected to if the condition is `Some(false)`.
redirect_path: PathFn,
/// Nested child routes.
children: RouteChildren<Children>,
/// The mode that this route prefers during server-side rendering.
/// Defaults to out-of-order streaming.
#[prop(optional)]
ssr: SsrMode,
) -> $ret
where
Segments: PossibleRouteMatch + Clone + Send + 'static,
Children: MatchNestedRoutes + Send + Clone + 'static,
ViewFn: Fn() -> View + Send + Clone + 'static,
View: IntoView + 'static,
C: Fn() -> Option<bool> + Send + Clone + 'static,
PathFn: Fn() -> P + Send + Clone + 'static,
P: Display + 'static,
{
let fallback = move || fallback.run();
let children = children.into_inner();
let view = move || {
let condition = condition.clone();
let redirect_path = redirect_path.clone();
let fallback = fallback.clone();
let owner = owner.clone();
Unsuspend::new(move || match condition {
// reset the owner so that things like providing context work
// otherwise, this will be a child owner nested within the Transition, not
// the parent owner of the Outlet
//
// clippy: not redundant, a FnOnce vs FnMut issue
#[allow(clippy::redundant_closure)]
Some(true) => EitherOf3::A(owner.with(|| view())),
#[allow(clippy::unit_arg)]
Some(false) => EitherOf3::B(
view! { <Redirect path=redirect_path()/> }.into_inner(),
),
None => EitherOf3::C(fallback()),
})
}
};
(view! { <Transition fallback>{view}</Transition> }).into_any()
let view = view.clone();
let owner = Owner::current().unwrap();
let view = {
let fallback = fallback.clone();
move || {
let condition = condition();
let view = view.clone();
let redirect_path = redirect_path.clone();
let fallback = fallback.clone();
let owner = owner.clone();
Unsuspend::new(move || match condition {
// reset the owner so that things like providing context work
// otherwise, this will be a child owner nested within the Transition, not
// the parent owner of the Outlet
//
// clippy: not redundant, a FnOnce vs FnMut issue
#[allow(clippy::redundant_closure)]
Some(true) => EitherOf3::A(owner.with(|| view())),
#[allow(clippy::unit_arg)]
Some(false) => EitherOf3::B(
view! { <Redirect path=redirect_path()/> }
.into_inner(),
),
None => EitherOf3::C(fallback()),
})
}
};
(view! { <Transition fallback>{view}</Transition> }).into_any()
};
NestedRoute::new(path, view)
.ssr_mode(ssr)
.child(children)
.into_maybe_erased()
}
};
NestedRoute::new(path, view).ssr_mode(ssr).child(children)
}
#[cfg(erase_components)]
define_protected_parent_route!(crate::any_nested_route::AnyNestedRoute);
#[cfg(not(erase_components))]
define_protected_parent_route!(NestedRoute<Segments, Children, (), impl Fn() -> AnyView + Send + Clone>);
/// Redirects the user to a new URL, whether on the client side or on the server
/// side. If rendered on the server, this sets a `302` status code and sets a `Location`
/// header. If rendered in the browser, it uses client-side navigation to redirect.

View File

@@ -10,6 +10,7 @@ use crate::{
use any_spawner::Executor;
use either_of::Either;
use futures::FutureExt;
use leptos::attr::{any_attribute::AnyAttribute, Attribute};
use reactive_graph::{
computed::{ArcMemo, ScopedFuture},
owner::{provide_context, Owner},
@@ -26,7 +27,7 @@ use tachys::{
view::{
add_attr::AddAnyAttr,
any_view::{AnyView, AnyViewState, IntoAny},
Mountable, Position, PositionState, Render, RenderHtml,
MarkBranch, Mountable, Position, PositionState, Render, RenderHtml,
},
};
@@ -68,6 +69,10 @@ impl Mountable for FlatRoutesViewState {
fn insert_before_this(&self, child: &mut dyn Mountable) -> bool {
self.view.insert_before_this(child)
}
fn elements(&self) -> Vec<tachys::renderer::types::Element> {
self.view.elements()
}
}
impl<Loc, Defs, FalFn, Fal> Render for FlatRoutesView<Loc, Defs, FalFn>
@@ -343,7 +348,7 @@ impl<Loc, Defs, FalFn, Fal> AddAnyAttr for FlatRoutesView<Loc, Defs, FalFn>
where
Loc: LocationProvider + Send,
Defs: MatchNestedRoutes + Send + 'static,
FalFn: FnOnce() -> Fal + Send,
FalFn: FnOnce() -> Fal + Send + 'static,
Fal: RenderHtml + 'static,
{
type Output<SomeNewAttr: leptos::attr::Attribute> =
@@ -360,6 +365,112 @@ where
}
}
#[derive(Debug)]
pub(crate) struct MatchedRoute(pub String, pub AnyView);
impl Render for MatchedRoute {
type State = <AnyView as Render>::State;
fn build(self) -> Self::State {
self.1.build()
}
fn rebuild(self, state: &mut Self::State) {
self.1.rebuild(state);
}
}
impl AddAnyAttr for MatchedRoute {
type Output<SomeNewAttr: Attribute> = Self;
fn add_any_attr<NewAttr: Attribute>(
self,
attr: NewAttr,
) -> Self::Output<NewAttr>
where
Self::Output<NewAttr>: RenderHtml,
{
let MatchedRoute(id, view) = self;
MatchedRoute(id, view.add_any_attr(attr).into_any())
}
}
impl RenderHtml for MatchedRoute {
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = 0;
fn dry_resolve(&mut self) {
self.1.dry_resolve();
}
async fn resolve(self) -> Self::AsyncOutput {
let MatchedRoute(id, view) = self;
let view = view.resolve().await;
MatchedRoute(id, view)
}
fn to_html_with_buf(
self,
buf: &mut String,
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
if mark_branches {
buf.open_branch(&self.0);
}
self.1.to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
if mark_branches {
buf.close_branch(&self.0);
}
}
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
self,
buf: &mut StreamBuilder,
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
if mark_branches {
buf.open_branch(&self.0);
}
self.1.to_html_async_with_buf::<OUT_OF_ORDER>(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
if mark_branches {
buf.close_branch(&self.0);
}
}
fn hydrate<const FROM_SERVER: bool>(
self,
cursor: &Cursor,
position: &PositionState,
) -> Self::State {
self.1.hydrate::<FROM_SERVER>(cursor, position)
}
fn into_owned(self) -> Self::Owned {
self
}
}
impl<Loc, Defs, FalFn, Fal> FlatRoutesView<Loc, Defs, FalFn>
where
Loc: LocationProvider + Send,
@@ -392,6 +503,7 @@ where
let view = match new_match {
None => (self.fallback)().into_any(),
Some(new_match) => {
let id = new_match.as_matched().to_string();
let (view, _) = new_match.into_view_and_child();
let view = owner
.with(|| {
@@ -404,6 +516,7 @@ where
})
.now_or_never()
.expect("async route used in SSR");
let view = MatchedRoute(id, view);
view.into_any()
}
};
@@ -416,10 +529,11 @@ impl<Loc, Defs, FalFn, Fal> RenderHtml for FlatRoutesView<Loc, Defs, FalFn>
where
Loc: LocationProvider + Send,
Defs: MatchNestedRoutes + Send + 'static,
FalFn: FnOnce() -> Fal + Send,
FalFn: FnOnce() -> Fal + Send + 'static,
Fal: RenderHtml + 'static,
{
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = <Either<Fal, AnyView> as RenderHtml>::MIN_LENGTH;
@@ -435,6 +549,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
// if this is being run on the server for the first time, generating all possible routes
if RouteList::is_generating() {
@@ -481,7 +596,13 @@ where
RouteList::register(RouteList::from(routes));
} else {
let view = self.choose_ssr();
view.to_html_with_buf(buf, position, escape, mark_branches);
view.to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
}
}
@@ -491,6 +612,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
@@ -500,6 +622,7 @@ where
position,
escape,
mark_branches,
extra_attrs,
)
}
@@ -604,4 +727,8 @@ where
}
}
}
fn into_owned(self) -> Self::Owned {
self
}
}

View File

@@ -0,0 +1,55 @@
use super::ChooseView;
use futures::FutureExt;
use std::{future::Future, pin::Pin};
use tachys::{erased::Erased, view::any_view::AnyView};
/// A type-erased [`ChooseView`].
pub struct AnyChooseView {
value: Erased,
clone: fn(&Erased) -> AnyChooseView,
choose: fn(Erased) -> Pin<Box<dyn Future<Output = AnyView>>>,
preload: for<'a> fn(&'a Erased) -> Pin<Box<dyn Future<Output = ()> + 'a>>,
}
impl Clone for AnyChooseView {
fn clone(&self) -> Self {
(self.clone)(&self.value)
}
}
impl AnyChooseView {
pub(crate) fn new<T: ChooseView>(value: T) -> Self {
fn clone<T: ChooseView>(value: &Erased) -> AnyChooseView {
AnyChooseView::new(value.get_ref::<T>().clone())
}
fn choose<T: ChooseView>(
value: Erased,
) -> Pin<Box<dyn Future<Output = AnyView>>> {
value.into_inner::<T>().choose().boxed_local()
}
fn preload<'a, T: ChooseView>(
value: &'a Erased,
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
value.get_ref::<T>().preload().boxed_local()
}
Self {
value: Erased::new(value),
clone: clone::<T>,
choose: choose::<T>,
preload: preload::<T>,
}
}
}
impl ChooseView for AnyChooseView {
async fn choose(self) -> AnyView {
(self.choose)(self.value).await
}
async fn preload(&self) {
(self.preload)(&self.value).await;
}
}

View File

@@ -130,3 +130,34 @@ tuples!(EitherOf13 => A, B, C, D, E, F, G, H, I, J, K, L, M);
tuples!(EitherOf14 => A, B, C, D, E, F, G, H, I, J, K, L, M, N);
tuples!(EitherOf15 => A, B, C, D, E, F, G, H, I, J, K, L, M, N, O);
tuples!(EitherOf16 => A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P);
/// A version of [`IntoMaybeErased`] for the [`ChooseView`] trait.
pub trait IntoChooseViewMaybeErased {
/// The type of the erased view.
type Output: IntoChooseViewMaybeErased;
/// Erase the type of the view.
fn into_maybe_erased(self) -> Self::Output;
}
impl<T> IntoChooseViewMaybeErased for T
where
T: ChooseView + Send + Clone + 'static,
{
#[cfg(erase_components)]
type Output = crate::matching::any_choose_view::AnyChooseView;
#[cfg(not(erase_components))]
type Output = Self;
fn into_maybe_erased(self) -> Self::Output {
#[cfg(erase_components)]
{
crate::matching::any_choose_view::AnyChooseView::new(self)
}
#[cfg(not(erase_components))]
{
self
}
}
}

View File

@@ -1,4 +1,5 @@
use super::{PartialPathMatch, PathSegment};
use std::sync::Arc;
mod param_segments;
mod static_segment;
mod tuples;
@@ -11,9 +12,37 @@ pub use static_segment::*;
/// This is a "horizontal" matching: i.e., it treats a tuple of route segments
/// as subsequent segments of the URL and tries to match them all.
pub trait PossibleRouteMatch {
const OPTIONAL: bool = false;
fn optional(&self) -> bool;
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>>;
fn generate_path(&self, path: &mut Vec<PathSegment>);
}
impl PossibleRouteMatch for Box<dyn PossibleRouteMatch + Send + Sync> {
fn optional(&self) -> bool {
(**self).optional()
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
(**self).test(path)
}
fn generate_path(&self, path: &mut Vec<PathSegment>) {
(**self).generate_path(path);
}
}
impl PossibleRouteMatch for Arc<dyn PossibleRouteMatch + Send + Sync> {
fn optional(&self) -> bool {
(**self).optional()
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
(**self).test(path)
}
fn generate_path(&self, path: &mut Vec<PathSegment>) {
(**self).generate_path(path);
}
}

View File

@@ -35,6 +35,10 @@ use std::borrow::Cow;
pub struct ParamSegment(pub &'static str);
impl PossibleRouteMatch for ParamSegment {
fn optional(&self) -> bool {
false
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
let mut matched_len = 0;
let mut param_offset = 0;
@@ -121,6 +125,10 @@ impl PossibleRouteMatch for ParamSegment {
pub struct WildcardSegment(pub &'static str);
impl PossibleRouteMatch for WildcardSegment {
fn optional(&self) -> bool {
false
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
let mut matched_len = 0;
let mut param_offset = 0;
@@ -158,7 +166,9 @@ impl PossibleRouteMatch for WildcardSegment {
pub struct OptionalParamSegment(pub &'static str);
impl PossibleRouteMatch for OptionalParamSegment {
const OPTIONAL: bool = true;
fn optional(&self) -> bool {
true
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
let mut matched_len = 0;

View File

@@ -2,6 +2,10 @@ use super::{PartialPathMatch, PathSegment, PossibleRouteMatch};
use std::fmt::Debug;
impl PossibleRouteMatch for () {
fn optional(&self) -> bool {
false
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
Some(PartialPathMatch::new(path, vec![], ""))
}
@@ -54,6 +58,10 @@ impl AsPath for &'static str {
pub struct StaticSegment<T: AsPath>(pub T);
impl<T: AsPath> PossibleRouteMatch for StaticSegment<T> {
fn optional(&self) -> bool {
false
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
let mut matched_len = 0;
let mut test = path.chars().peekable();

View File

@@ -8,14 +8,20 @@ macro_rules! tuples {
$first: PossibleRouteMatch,
$($ty: PossibleRouteMatch),*,
{
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
// on the first run, include all optionals
let mut include_optionals = {
[$first::OPTIONAL, $($ty::OPTIONAL),*].into_iter().filter(|n| *n).count()
};
fn optional(&self) -> bool {
#[allow(non_snake_case)]
let ($first, $($ty,)*) = &self;
[$first.optional(), $($ty.optional()),*].into_iter().any(|n| n)
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
#[allow(non_snake_case)]
let ($first, $($ty,)*) = &self;
// on the first run, include all optionals
let mut include_optionals = {
[$first.optional(), $($ty.optional()),*].into_iter().filter(|n| *n).count()
};
loop {
let mut nth_field = 0;
@@ -25,7 +31,7 @@ macro_rules! tuples {
let mut p = Vec::new();
let mut m = String::new();
if !$first::OPTIONAL || nth_field < include_optionals {
if !$first.optional() || nth_field < include_optionals {
match $first.test(r) {
None => {
return None;
@@ -40,16 +46,16 @@ macro_rules! tuples {
matched_len += m.len();
$(
if $ty::OPTIONAL {
if $ty.optional() {
nth_field += 1;
}
if !$ty::OPTIONAL || nth_field < include_optionals {
if !$ty.optional() || nth_field < include_optionals {
let PartialPathMatch {
remaining,
matched,
params
} = match $ty.test(r) {
None => if $ty::OPTIONAL {
None => if $ty.optional() {
return None;
} else {
if include_optionals == 0 {
@@ -90,6 +96,10 @@ where
Self: core::fmt::Debug,
A: PossibleRouteMatch,
{
fn optional(&self) -> bool {
self.0.optional()
}
fn test<'a>(&self, path: &'a str) -> Option<PartialPathMatch<'a>> {
let remaining = path;
let PartialPathMatch {

View File

@@ -1,5 +1,6 @@
#![allow(missing_docs)]
mod any_choose_view;
mod choose_view;
mod path_segment;
pub(crate) mod resolve_path;

View File

@@ -0,0 +1,100 @@
#![allow(clippy::type_complexity)]
use crate::{
matching::any_choose_view::AnyChooseView, ChooseView, MatchInterface,
MatchParams, RouteMatchId,
};
use std::{borrow::Cow, fmt::Debug};
use tachys::erased::ErasedLocal;
/// A type-erased container for any [`MatchParams'] + [`MatchInterface`].
pub struct AnyNestedMatch {
value: ErasedLocal,
to_params: fn(&ErasedLocal) -> Vec<(Cow<'static, str>, String)>,
as_id: fn(&ErasedLocal) -> RouteMatchId,
as_matched: for<'a> fn(&'a ErasedLocal) -> &'a str,
into_view_and_child:
fn(ErasedLocal) -> (AnyChooseView, Option<AnyNestedMatch>),
}
impl Debug for AnyNestedMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnyNestedMatch").finish_non_exhaustive()
}
}
/// Converts anything implementing [`MatchParams'] + [`MatchInterface`] into an erased type.
pub trait IntoAnyNestedMatch {
/// Wraps the nested route.
fn into_any_nested_match(self) -> AnyNestedMatch;
}
impl<T> IntoAnyNestedMatch for T
where
T: MatchParams + MatchInterface + 'static,
{
fn into_any_nested_match(self) -> AnyNestedMatch {
let value = ErasedLocal::new(self);
fn to_params<T: MatchParams + 'static>(
value: &ErasedLocal,
) -> Vec<(Cow<'static, str>, String)> {
let value = value.get_ref::<T>();
value.to_params()
}
fn as_id<T: MatchInterface + 'static>(
value: &ErasedLocal,
) -> RouteMatchId {
let value = value.get_ref::<T>();
value.as_id()
}
fn as_matched<T: MatchInterface + 'static>(
value: &ErasedLocal,
) -> &str {
let value = value.get_ref::<T>();
value.as_matched()
}
fn into_view_and_child<T: MatchInterface + 'static>(
value: ErasedLocal,
) -> (AnyChooseView, Option<AnyNestedMatch>) {
let value = value.into_inner::<T>();
let (view, child) = value.into_view_and_child();
(
AnyChooseView::new(view),
child.map(|child| child.into_any_nested_match()),
)
}
AnyNestedMatch {
value,
to_params: to_params::<T>,
as_id: as_id::<T>,
as_matched: as_matched::<T>,
into_view_and_child: into_view_and_child::<T>,
}
}
}
impl MatchParams for AnyNestedMatch {
fn to_params(&self) -> Vec<(Cow<'static, str>, String)> {
(self.to_params)(&self.value)
}
}
impl MatchInterface for AnyNestedMatch {
type Child = AnyNestedMatch;
fn as_id(&self) -> RouteMatchId {
(self.as_id)(&self.value)
}
fn as_matched(&self) -> &str {
(self.as_matched)(&self.value)
}
fn into_view_and_child(self) -> (impl ChooseView, Option<Self::Child>) {
(self.into_view_and_child)(self.value)
}
}

View File

@@ -0,0 +1,100 @@
#![allow(clippy::type_complexity)]
use crate::{
matching::nested::any_nested_match::{AnyNestedMatch, IntoAnyNestedMatch},
GeneratedRouteData, MatchNestedRoutes, RouteMatchId,
};
use std::fmt::Debug;
use tachys::{erased::Erased, prelude::IntoMaybeErased};
/// A type-erased container for any [`MatchNestedRoutes`].
pub struct AnyNestedRoute {
value: Erased,
clone: fn(&Erased) -> AnyNestedRoute,
match_nested:
for<'a> fn(
&'a Erased,
&'a str,
)
-> (Option<(RouteMatchId, AnyNestedMatch)>, &'a str),
generate_routes: fn(&Erased) -> Vec<GeneratedRouteData>,
}
impl Clone for AnyNestedRoute {
fn clone(&self) -> Self {
(self.clone)(&self.value)
}
}
impl Debug for AnyNestedRoute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnyNestedRoute").finish_non_exhaustive()
}
}
impl IntoMaybeErased for AnyNestedRoute {
type Output = Self;
fn into_maybe_erased(self) -> Self::Output {
self
}
}
/// Converts anything implementing [`MatchNestedRoutes`] into [`AnyNestedRoute`].
pub trait IntoAnyNestedRoute {
/// Wraps the nested route.
fn into_any_nested_route(self) -> AnyNestedRoute;
}
impl<T> IntoAnyNestedRoute for T
where
T: MatchNestedRoutes + Send + Clone + 'static,
{
fn into_any_nested_route(self) -> AnyNestedRoute {
fn clone<T: MatchNestedRoutes + Send + Clone + 'static>(
value: &Erased,
) -> AnyNestedRoute {
value.get_ref::<T>().clone().into_any_nested_route()
}
fn match_nested<'a, T: MatchNestedRoutes + Send + Clone + 'static>(
value: &'a Erased,
path: &'a str,
) -> (Option<(RouteMatchId, AnyNestedMatch)>, &'a str) {
let (maybe_match, path) = value.get_ref::<T>().match_nested(path);
(
maybe_match
.map(|(id, matched)| (id, matched.into_any_nested_match())),
path,
)
}
fn generate_routes<T: MatchNestedRoutes + Send + Clone + 'static>(
value: &Erased,
) -> Vec<GeneratedRouteData> {
value.get_ref::<T>().generate_routes().into_iter().collect()
}
AnyNestedRoute {
value: Erased::new(self),
clone: clone::<T>,
match_nested: match_nested::<T>,
generate_routes: generate_routes::<T>,
}
}
}
impl MatchNestedRoutes for AnyNestedRoute {
type Data = AnyNestedMatch;
type Match = AnyNestedMatch;
fn match_nested<'a>(
&'a self,
path: &'a str,
) -> (Option<(RouteMatchId, Self::Match)>, &'a str) {
(self.match_nested)(&self.value, path)
}
fn generate_routes(&self) -> impl IntoIterator<Item = GeneratedRouteData> {
(self.generate_routes)(&self.value)
}
}

View File

@@ -1,6 +1,6 @@
use super::{
MatchInterface, MatchNestedRoutes, PartialPathMatch, PathSegment,
PossibleRouteMatch, RouteMatchId,
IntoChooseViewMaybeErased, MatchInterface, MatchNestedRoutes,
PartialPathMatch, PathSegment, PossibleRouteMatch, RouteMatchId,
};
use crate::{ChooseView, GeneratedRouteData, MatchParams, Method, SsrMode};
use core::{fmt, iter};
@@ -10,7 +10,10 @@ use std::{
collections::HashSet,
sync::atomic::{AtomicU16, Ordering},
};
use tachys::prelude::IntoMaybeErased;
pub mod any_nested_match;
pub mod any_nested_route;
mod tuples;
pub(crate) static ROUTE_ID: AtomicU16 = AtomicU16::new(1);
@@ -26,6 +29,31 @@ pub struct NestedRoute<Segments, Children, Data, View> {
ssr_mode: SsrMode,
}
impl<Segments, Children, Data, View> IntoMaybeErased
for NestedRoute<Segments, Children, Data, View>
where
Self: MatchNestedRoutes + Send + Clone + 'static,
{
#[cfg(erase_components)]
type Output = any_nested_route::AnyNestedRoute;
#[cfg(not(erase_components))]
type Output = Self;
fn into_maybe_erased(self) -> Self::Output {
#[cfg(erase_components)]
{
use any_nested_route::IntoAnyNestedRoute;
self.into_any_nested_route()
}
#[cfg(not(erase_components))]
{
self
}
}
}
impl<Segments, Children, Data, View> Clone
for NestedRoute<Segments, Children, Data, View>
where
@@ -48,16 +76,24 @@ where
}
impl<Segments, View> NestedRoute<Segments, (), (), View> {
pub fn new(path: Segments, view: View) -> Self
pub fn new(
path: Segments,
view: View,
) -> NestedRoute<
Segments,
(),
(),
<View as IntoChooseViewMaybeErased>::Output,
>
where
View: ChooseView,
{
Self {
NestedRoute {
id: ROUTE_ID.fetch_add(1, Ordering::Relaxed),
segments: path,
children: None,
data: (),
view,
view: view.into_maybe_erased(),
methods: [Method::Get].into(),
ssr_mode: Default::default(),
}
@@ -151,7 +187,7 @@ impl<Segments, Children, Data, View> MatchNestedRoutes
for NestedRoute<Segments, Children, Data, View>
where
Self: 'static,
Segments: PossibleRouteMatch + std::fmt::Debug,
Segments: PossibleRouteMatch,
Children: MatchNestedRoutes,
Children::Match: MatchParams,
Children: 'static,

View File

@@ -3,6 +3,7 @@ use crate::{ChooseView, GeneratedRouteData, MatchParams};
use core::iter;
use either_of::*;
use std::borrow::Cow;
use tachys::view::iterators::StaticVec;
impl MatchParams for () {
fn to_params(&self) -> Vec<(Cow<'static, str>, String)> {
@@ -181,6 +182,32 @@ where
}
}
impl<T> MatchNestedRoutes for StaticVec<T>
where
T: MatchNestedRoutes,
{
type Data = Vec<T::Data>;
type Match = T::Match;
fn match_nested<'a>(
&'a self,
path: &'a str,
) -> (Option<(RouteMatchId, Self::Match)>, &'a str) {
for item in self.iter() {
if let (Some((id, matched)), remaining) = item.match_nested(path) {
return (Some((id, matched)), remaining);
}
}
(None, path)
}
fn generate_routes(
&self,
) -> impl IntoIterator<Item = GeneratedRouteData> + '_ {
self.iter().flat_map(T::generate_routes)
}
}
macro_rules! chain_generated {
($first:expr, $second:expr, ) => {
$first.chain($second)

View File

@@ -1,4 +1,5 @@
use crate::{
flat_router::MatchedRoute,
hooks::Matched,
location::{LocationProvider, Url},
matching::RouteDefs,
@@ -10,7 +11,7 @@ use crate::{
use any_spawner::Executor;
use either_of::{Either, EitherOf3};
use futures::{channel::oneshot, future::join_all, FutureExt};
use leptos::{component, oco::Oco};
use leptos::{attr::any_attribute::AnyAttribute, component, oco::Oco};
use or_poisoned::OrPoisoned;
use reactive_graph::{
computed::{ArcMemo, ScopedFuture},
@@ -228,8 +229,8 @@ where
impl<Loc, Defs, Fal, FalFn> AddAnyAttr for NestedRoutesView<Loc, Defs, FalFn>
where
Loc: LocationProvider + Send,
Defs: MatchNestedRoutes + Send,
FalFn: FnOnce() -> Fal + Send,
Defs: MatchNestedRoutes + Send + 'static,
FalFn: FnOnce() -> Fal + Send + 'static,
Fal: RenderHtml + 'static,
{
type Output<SomeNewAttr: leptos::attr::Attribute> =
@@ -249,11 +250,12 @@ where
impl<Loc, Defs, FalFn, Fal> RenderHtml for NestedRoutesView<Loc, Defs, FalFn>
where
Loc: LocationProvider + Send,
Defs: MatchNestedRoutes + Send,
FalFn: FnOnce() -> Fal + Send,
Defs: MatchNestedRoutes + Send + 'static,
FalFn: FnOnce() -> Fal + Send + 'static,
Fal: RenderHtml + 'static,
{
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = 0; // TODO
@@ -269,6 +271,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
// if this is being run on the server for the first time, generating all possible routes
if RouteList::is_generating() {
@@ -348,7 +351,13 @@ where
outer_owner.with(|| Either::Right(Outlet().into_any()))
}
};
view.to_html_with_buf(buf, position, escape, mark_branches);
view.to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
}
}
@@ -358,6 +367,7 @@ where
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
@@ -400,6 +410,7 @@ where
position,
escape,
mark_branches,
extra_attrs,
);
}
@@ -456,6 +467,10 @@ where
view,
}
}
fn into_owned(self) -> Self::Owned {
self
}
}
type OutletViewFn = Box<dyn FnMut() -> Suspend<AnyView> + Send>;
@@ -628,21 +643,28 @@ where
async move {
provide_context(params_including_parents);
provide_context(url);
provide_context(matched);
provide_context(matched.clone());
view.preload().await;
*view_fn.lock().or_poisoned() = Box::new(move || {
let view = view.clone();
owner.with(|| {
Suspend::new(Box::pin(async move {
let view = SendWrapper::new(ScopedFuture::new(
view.choose(),
));
let view = view.await;
OwnedView::new(view).into_any()
})
as Pin<
Box<dyn Future<Output = AnyView> + Send>,
>)
owner.with({
let matched = matched.clone();
move || {
Suspend::new(Box::pin(async move {
let view = SendWrapper::new(
ScopedFuture::new(view.choose()),
);
let view = view.await;
let view =
MatchedRoute(matched.0.get(), view);
OwnedView::new(view).into_any()
})
as Pin<
Box<
dyn Future<Output = AnyView> + Send,
>,
>)
}
})
});
trigger
@@ -877,6 +899,10 @@ where
fn insert_before_this(&self, child: &mut dyn Mountable) -> bool {
self.view.insert_before_this(child)
}
fn elements(&self) -> Vec<tachys::renderer::types::Element> {
self.view.elements()
}
}
/// Displays the child route nested in a parent route, allowing you to control exactly where

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