Skip to content

Commit

Permalink
Merge pull request #2264 from vuejs/refactor/typed-routes
Browse files Browse the repository at this point in the history
feat: add generic location types
  • Loading branch information
posva committed Jun 21, 2024
2 parents a91123f + edff284 commit 19142f5
Show file tree
Hide file tree
Showing 102 changed files with 2,335 additions and 2,409 deletions.
19 changes: 10 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ name: test

on:
push:
branches:
- main
paths-ignore:
- 'packages/docs/**'
- 'packages/playground/**'
pull_request:
branches:
- main
paths-ignore:
- 'packages/docs/**'
- 'packages/playground/**'
Expand All @@ -15,14 +19,12 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
version: 8.5.0
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'pnpm'
node-version: 'lts/*'
cache: pnpm
- name: 'BrowserStack Env Setup'
uses: 'browserstack/github-actions/setup-env@master'
# forks do not have access to secrets so just skip this
Expand All @@ -34,10 +36,9 @@ jobs:
- run: pnpm install
- run: pnpm run lint
- run: pnpm run -r test:types
- run: pnpm run -r test:unit
- run: pnpm run -r build
- run: pnpm run -r build:dts
- run: pnpm run -r test:dts
- run: pnpm run -r test:unit

# e2e tests that that run locally
- run: pnpm run -r test:e2e:ci
Expand Down
3 changes: 3 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
coverage:
status:
patch: off
4 changes: 2 additions & 2 deletions packages/docs/guide/advanced/navigation-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ Global before guards are called in creation order, whenever a navigation is trig

Every guard function receives two arguments:

- **`to`**: the target route location [in a normalized format](../../api/interfaces/RouteLocationNormalized.md) being navigated to.
- **`from`**: the current route location [in a normalized format](../../api/interfaces/RouteLocationNormalized.md) being navigated away from.
- **`to`**: the target route location [in a normalized format](../../api/#RouteLocationNormalized) being navigated to.
- **`from`**: the current route location [in a normalized format](../../api/#RouteLocationNormalized) being navigated away from.

And can optionally return any of the following values:

Expand Down
68 changes: 62 additions & 6 deletions packages/docs/guide/advanced/typed-routes.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,66 @@
# Typed Routes (v4.1.0+)
# Typed Routes <Badge type="tip" text="v4.4.0+" />

::: danger ‼️ Experimental feature

Starting from v4.1.0, we are introducing a new feature called Typed Routes. This **experimental** feature is enabled through a Vite/webpack/Rollup plugin.
::: danger
‼️ Experimental feature
:::

![RouterLink to autocomplete](https://user-images.githubusercontent.com/664177/176442066-c4e7fa31-4f06-4690-a49f-ed0fd880dfca.png)

[Check the v4.1 release notes](https://github.com/vuejs/router/releases/tag/v4.1.0) for more information about this feature.
[Check out the plugin](https://github.com/posva/unplugin-vue-router) GitHub repository for installation instructions and documentation.
It's possible to configure the router to have a _map_ of typed routes. While this can be done manually, it is recommended to use the [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) plugin to generate the routes and the types automatically.

## Manual Configuration

Here is an example of how to manually configure typed routes:

```ts
// import the `RouteRecordInfo` type from vue-router to type your routes
import type { RouteRecordInfo } from 'vue-router'

// Define an interface of routes
export interface RouteNamedMap {
// each key is a name
home: RouteRecordInfo<
// here we have the same name
'home',
// this is the path, it will appear in autocompletion
'/',
// these are the raw params. In this case, there are no params allowed
Record<never, never>,
// these are the normalized params
Record<never, never>
>
// repeat for each route..
// Note you can name them whatever you want
'named-param': RouteRecordInfo<
'named-param',
'/:name',
{ name: string | number }, // raw value
{ name: string } // normalized value
>
'article-details': RouteRecordInfo<
'article-details',
'/articles/:id+',
{ id: Array<number | string> },
{ id: string[] }
>
'not-found': RouteRecordInfo<
'not-found',
'/:path(.*)',
{ path: string },
{ path: string }
>
}

// Last, you will need to augment the Vue Router types with this map of routes
declare module 'vue-router' {
interface TypesConfig {
RouteNamedMap: RouteNamedMap
}
}
```

::: tip

This is indeed tedious and error-prone. That's why it's recommended to use [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to generate the routes and the types automatically.

:::
14 changes: 7 additions & 7 deletions packages/docs/guide/essentials/active-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@ The RouterLink component adds two CSS classes to active links, `router-link-acti

## When are links active?

A RouterLink is considered to be ***active*** if:
A RouterLink is considered to be **_active_** if:

1. It matches the same route record (i.e. configured route) as the current location.
2. It has the same values for the `params` as the current location.

If you're using [nested routes](./nested-routes), any links to ancestor routes will also be considered active if the relevant `params` match.

Other route properties, such as the [`query`](../../api/interfaces/RouteLocationNormalized#query), are not taken into account.
Other route properties, such as the [`query`](../../api/interfaces/RouteLocationBase.html#query), are not taken into account.

The path doesn't necessarily need to be a perfect match. For example, using an [`alias`](./redirect-and-alias#Alias) would still be considered a match, so long as it resolves to the same route record and `params`.

If a route has a [`redirect`](./redirect-and-alias#Redirect), it won't be followed when checking whether a link is active.

## Exact active links

An ***exact*** match does not include ancestor routes.
An **_exact_** match does not include ancestor routes.

Let's imagine we have the following routes:

Expand All @@ -34,9 +34,9 @@ const routes = [
{
path: 'role/:roleId',
component: Role,
}
]
}
},
],
},
]
```

Expand All @@ -51,7 +51,7 @@ Then consider these two links:
</RouterLink>
```

If the current location path is `/user/erina/role/admin` then these would both be considered _active_, so the class `router-link-active` would be applied to both links. But only the second link would be considered _exact_, so only that second link would have the class `router-link-exact-active`.
If the current location path is `/user/erina/role/admin` then these would both be considered _active_, so the class `router-link-active` would be applied to both links. But only the second link would be considered _exact_, so only that second link would have the class `router-link-exact-active`.

## Configuring the classes

Expand Down
17 changes: 10 additions & 7 deletions packages/docs/guide/essentials/dynamic-matching.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ A _param_ is denoted by a colon `:`. When a route is matched, the value of its _

You can have multiple _params_ in the same route, and they will map to corresponding fields on `route.params`. Examples:

| pattern | matched path | route.params |
| ------------------------------ | ------------------------ | -------------------------------------- |
| /users/:username | /users/eduardo | `{ username: 'eduardo' }` |
| pattern | matched path | route.params |
| ------------------------------ | ------------------------ | ---------------------------------------- |
| /users/:username | /users/eduardo | `{ username: 'eduardo' }` |
| /users/:username/posts/:postId | /users/eduardo/posts/123 | `{ username: 'eduardo', postId: '123' }` |

In addition to `route.params`, the `route` object also exposes other useful information such as `route.query` (if there is a query in the URL), `route.hash`, etc. You can check out the full details in the [API Reference](../../api/interfaces/RouteLocationNormalized.md).
In addition to `route.params`, the `route` object also exposes other useful information such as `route.query` (if there is a query in the URL), `route.hash`, etc. You can check out the full details in the [API Reference](../../api/#RouteLocationNormalized).

A working demo of this example can be found [here](https://codesandbox.io/s/route-params-vue-router-examples-mlb14?from-embed&initialpath=%2Fusers%2Feduardo%2Fposts%2F1).

Expand Down Expand Up @@ -69,9 +69,12 @@ import { useRoute } from 'vue-router'
const route = useRoute()
watch(() => route.params.id, (newId, oldId) => {
// react to route changes...
})
watch(
() => route.params.id,
(newId, oldId) => {
// react to route changes...
}
)
</script>
```

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/guide/migration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ Note this will work if `path` was `/parent/` as the relative location `home` to

Decoded values in `params`, `query`, and `hash` are now consistent no matter where the navigation is initiated (older browsers will still produce unencoded `path` and `fullPath`). The initial navigation should yield the same results as in-app navigations.

Given any [normalized route location](/api/interfaces/RouteLocationNormalized.md):
Given any [normalized route location](/api/#RouteLocationNormalized):

- Values in `path`, `fullPath` are not decoded anymore. They will appear as provided by the browser (most browsers provide them encoded). e.g. directly writing on the address bar `https://example.com/hello world` will yield the encoded version: `https://example.com/hello%20world` and both `path` and `fullPath` will be `/hello%20world`.
- `hash` is now decoded, that way it can be copied over: `router.push({ hash: $route.hash })` and be used directly in [scrollBehavior](/api/interfaces/RouterOptions.md#scrollBehavior)'s `el` option.
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/typedoc-markdown.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const __dirname = path.dirname(new URL(import.meta.url).pathname)
const DEFAULT_OPTIONS = {
// disableOutputCheck: true,
cleanOutputDir: true,
excludeInternal: true,
excludeInternal: false,
readme: 'none',
out: path.resolve(__dirname, './api'),
entryDocument: 'index.md',
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/zh/guide/migration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ createRouter({
})
```

**原因**: Vue支持的所有浏览器都支持 [HTML5 History API](https://developer.mozilla.org/zh-CN/docs/Web/API/History_API),因此我们不再需要使用 `location.hash`,而可以直接使用 `history.pushState()`
**原因**: Vue 支持的所有浏览器都支持 [HTML5 History API](https://developer.mozilla.org/zh-CN/docs/Web/API/History_API),因此我们不再需要使用 `location.hash`,而可以直接使用 `history.pushState()`

### 删除了 `*`(星标或通配符)路由

Expand Down Expand Up @@ -436,7 +436,7 @@ const routes = [

<!-- TODO: translate chinese API entries -->

给定任何[规范化的路由地址](/zh/api/interfaces/RouteLocationNormalized.md):
给定任何[规范化的路由地址](/zh/api/#RouteLocationNormalized):

- `path`, `fullPath`中的值不再被解码了。例如,直接在地址栏上写 "<https://example.com/hello> world",将得到编码后的版本:"https://example.com/hello%20world",而 "path "和 "fullPath "都是"/hello%20world"。
- `hash` 现在被解码了,这样就可以复制过来。`router.push({ hash: $route.hash })` 可以直接用于 [scrollBehavior](/zh/api/interfaces/RouterOptions.md#Properties-scrollBehavior)`el` 配置中。
Expand Down
64 changes: 25 additions & 39 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -183,51 +183,37 @@
</div>
</template>

<script lang="ts">
import { defineComponent, inject, computed, ref } from 'vue'
<script lang="ts" setup>
import { inject, computed, ref } from 'vue'
import { scrollWaiter } from './scrollWaiter'
import { useLink, useRoute } from 'vue-router'
import { useLink, useRoute, RouterLink } from 'vue-router'
import AppLink from './AppLink.vue'
export default defineComponent({
name: 'App',
components: { AppLink },
setup() {
const route = useRoute()
const state = inject('state')
const viewName = ref('default')
const route = useRoute()
const state = inject('state')
const viewName = ref('default')
useLink({ to: '/' })
useLink({ to: '/documents/hello' })
useLink({ to: '/children' })
useLink({ to: '/' })
useLink({ to: '/documents/hello' })
useLink({ to: '/children' })
const currentLocation = computed(() => {
const { matched, ...rest } = route
return rest
})
const currentLocation = computed(() => {
const { matched, ...rest } = route
return rest
})
function flushWaiter() {
scrollWaiter.flush()
}
function setupWaiter() {
scrollWaiter.add()
}
function flushWaiter() {
scrollWaiter.flush()
}
function setupWaiter() {
scrollWaiter.add()
}
const nextUserLink = computed(
() => '/users/' + String((Number(route.params.id) || 0) + 1)
)
const nextUserLink = computed(
() => '/users/' + String((Number(route.params.id) || 0) + 1)
)
return {
currentLocation,
nextUserLink,
state,
flushWaiter,
setupWaiter,
viewName,
toggleViewName() {
viewName.value = viewName.value === 'default' ? 'other' : 'default'
},
}
},
})
function toggleViewName() {
viewName.value = viewName.value === 'default' ? 'other' : 'default'
}
</script>
43 changes: 43 additions & 0 deletions packages/playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ComponentPublicInstance } from 'vue'
import { router, routerHistory } from './router'
import { globalState } from './store'
import App from './App.vue'
import { useRoute, type ParamValue, type RouteRecordInfo } from 'vue-router'

declare global {
interface Window {
Expand All @@ -29,3 +30,45 @@ app.provide('state', globalState)
app.use(router)

window.vm = app.mount('#app')

export interface RouteNamedMap {
home: RouteRecordInfo<'home', '/', Record<never, never>, Record<never, never>>
'/[name]': RouteRecordInfo<
'/[name]',
'/:name',
{ name: ParamValue<true> },
{ name: ParamValue<false> }
>
'/[...path]': RouteRecordInfo<
'/[...path]',
'/:path(.*)',
{ path: ParamValue<true> },
{ path: ParamValue<false> }
>
}

declare module 'vue-router' {
interface TypesConfig {
RouteNamedMap: RouteNamedMap
}
}

function _ok() {
const r = useRoute()

if (r.name === '/[name]') {
r.params.name.toUpperCase()
// @ts-expect-error: Not existing route
} else if (r.name === 'nope') {
console.log('nope')
}

router.push({
name: '/[name]',
params: { name: 'hey' },
})

router
.resolve({ name: '/[name]', params: { name: 2 } })
.params.name.toUpperCase()
}
Loading

0 comments on commit 19142f5

Please sign in to comment.