From 3a73f42d1c699cfaca036599d92dbd46e165b19a Mon Sep 17 00:00:00 2001 From: Alex Korytskyi Date: Tue, 7 Mar 2023 09:17:42 +0200 Subject: [PATCH] feat(nuxt): support `trailingSlashBehavior` in `defineNuxtLink` (#19458) --- docs/3.api/2.components/4.nuxt-link.md | 2 + packages/nuxt/src/app/components/nuxt-link.ts | 30 +++++++- packages/nuxt/test/nuxt-link.test.ts | 45 +++++++++++- test/basic.test.ts | 72 ++++++++++++++++++ test/bundle.test.ts | 2 +- .../basic/pages/nuxt-link/trailing-slash.vue | 73 +++++++++++++++++++ 6 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/basic/pages/nuxt-link/trailing-slash.vue diff --git a/docs/3.api/2.components/4.nuxt-link.md b/docs/3.api/2.components/4.nuxt-link.md index ab2705cf86..87b87f876b 100644 --- a/docs/3.api/2.components/4.nuxt-link.md +++ b/docs/3.api/2.components/4.nuxt-link.md @@ -115,6 +115,7 @@ defineNuxtLink({ activeClass?: string; exactActiveClass?: string; prefetchedClass?: string; + trailingSlash?: 'append' | 'remove' }) => Component ``` @@ -123,6 +124,7 @@ defineNuxtLink({ - **activeClass**: A default class to apply on active links. Works the same as [Vue Router's `linkActiveClass` option](https://router.vuejs.org/api/#linkactiveclass). Defaults to Vue Router's default (`"router-link-active"`) - **exactActiveClass**: A default class to apply on exact active links. Works the same as [Vue Router's `linkExactActiveClass` option](https://router.vuejs.org/api/#linkexactactiveclass). Defaults to Vue Router's default (`"router-link-exact-active"`) - **prefetchedClass**: A default class to apply to links that have been prefetched. +- **trailingSlash**: An option to either add or remove trailing slashes in the `href`. If unset or not matching the valid values `append` or `remove`, it will be ignored. **This option is currently only available on the [Edge Channel](/docs/guide/going-further/edge-channel/).** ::LinkExample{link="/docs/examples/routing/nuxt-link"} :: diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 60e6e069c1..4bd7d8c937 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -1,7 +1,7 @@ import type { PropType, DefineComponent, ComputedRef } from 'vue' import { defineComponent, h, ref, resolveComponent, computed, onMounted, onBeforeUnmount } from 'vue' -import type { RouteLocationRaw } from 'vue-router' -import { hasProtocol, parseQuery, parseURL } from 'ufo' +import type { RouteLocation, RouteLocationRaw } from 'vue-router' +import { hasProtocol, parseQuery, parseURL, withoutTrailingSlash, withTrailingSlash } from 'ufo' import { preloadRouteComponents } from '../composables/preload' import { onNuxtReady } from '../composables/ready' @@ -19,6 +19,7 @@ export type NuxtLinkOptions = { activeClass?: string exactActiveClass?: string prefetchedClass?: string + trailingSlash?: 'append' | 'remove' } export type NuxtLinkProps = { @@ -53,6 +54,27 @@ export function defineNuxtLink (options: NuxtLinkOptions) { console.warn(`[${componentName}] \`${main}\` and \`${sub}\` cannot be used together. \`${sub}\` will be ignored.`) } } + const resolveTrailingSlashBehavior = ( + to: RouteLocationRaw, + resolve: (to: RouteLocationRaw) => RouteLocation & { href?: string } + ): RouteLocationRaw | RouteLocation => { + if (!to || (options.trailingSlash !== 'append' && options.trailingSlash !== 'remove')) { + return to + } + + const normalizeTrailingSlash = options.trailingSlash === 'append' ? withTrailingSlash : withoutTrailingSlash + if (typeof to === 'string') { + return normalizeTrailingSlash(to, true) + } + + const path = 'path' in to ? to.path : resolve(to).path + + return { + ...to, + name: undefined, // named routes would otherwise always override trailing slash behavior + path: normalizeTrailingSlash(path, true) + } + } return defineComponent({ name: componentName, @@ -148,7 +170,9 @@ export function defineNuxtLink (options: NuxtLinkOptions) { const to: ComputedRef = computed(() => { checkPropConflicts(props, 'to', 'href') - return props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute) + const path = props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute) + + return resolveTrailingSlashBehavior(path, router.resolve) }) // Resolving link type diff --git a/packages/nuxt/test/nuxt-link.test.ts b/packages/nuxt/test/nuxt-link.test.ts index 52d363b303..b07de1a833 100644 --- a/packages/nuxt/test/nuxt-link.test.ts +++ b/packages/nuxt/test/nuxt-link.test.ts @@ -1,5 +1,5 @@ import { expect, describe, it, vi } from 'vitest' -import type { RouteLocationRaw } from 'vue-router' +import type { RouteLocation, RouteLocationRaw } from 'vue-router' import type { NuxtLinkOptions, NuxtLinkProps } from '../src/app/components/nuxt-link' import { defineNuxtLink } from '../src/app/components/nuxt-link' @@ -15,7 +15,20 @@ vi.mock('vue', async () => { // Mocks Nuxt `useRouter()` vi.mock('../src/app/composables/router', () => ({ - useRouter: () => ({ resolve: ({ to }: { to: string }) => ({ href: to }) }) + useRouter: () => ({ + resolve: (route: string | RouteLocation & { to?: string }): Partial & { href?: string } => { + if (typeof route === 'string') { + return { href: route, path: route } + } + return route.to + ? { href: route.to } + : { + path: route.path || `/${route.name?.toString()}` || undefined, + query: route.query || undefined, + hash: route.hash || undefined + } + } + }) })) // Helpers for test visibility @@ -25,9 +38,9 @@ const INTERNAL = 'RouterLink' // Renders a `` const nuxtLink = ( props: NuxtLinkProps = {}, - NuxtLinkOptions: Partial = {} + nuxtLinkOptions: Partial = {} ): { type: string, props: Record, slots: unknown } => { - const component = defineNuxtLink({ componentName: 'NuxtLink', ...NuxtLinkOptions }) + const component = defineNuxtLink({ componentName: 'NuxtLink', ...nuxtLinkOptions }) const [type, _props, slots] = (component.setup as unknown as (props: NuxtLinkProps, context: { slots: Record unknown> }) => () => [string, Record, unknown])(props, { slots: { default: () => null } })() @@ -199,5 +212,29 @@ describe('nuxt-link:propsOrAttributes', () => { expect(nuxtLink({ to: '/to', ariaCurrentValue: 'step' }).props.ariaCurrentValue).toBe('step') }) }) + + describe('trailingSlashBehavior', () => { + it('append slash', () => { + const appendSlashOptions: NuxtLinkOptions = { trailingSlash: 'append' } + + expect(nuxtLink({ to: '/to' }, appendSlashOptions).props.to).toEqual('/to/') + expect(nuxtLink({ to: '/to/' }, appendSlashOptions).props.to).toEqual('/to/') + expect(nuxtLink({ to: { name: 'to' } }, appendSlashOptions).props.to).toHaveProperty('path', '/to/') + expect(nuxtLink({ to: { path: '/to' } }, appendSlashOptions).props.to).toHaveProperty('path', '/to/') + expect(nuxtLink({ href: '/to' }, appendSlashOptions).props.to).toEqual('/to/') + expect(nuxtLink({ to: '/to?param=1' }, appendSlashOptions).props.to).toEqual('/to/?param=1') + }) + + it('remove slash', () => { + const removeSlashOptions: NuxtLinkOptions = { trailingSlash: 'remove' } + + expect(nuxtLink({ to: '/to' }, removeSlashOptions).props.to).toEqual('/to') + expect(nuxtLink({ to: '/to/' }, removeSlashOptions).props.to).toEqual('/to') + expect(nuxtLink({ to: { name: 'to' } }, removeSlashOptions).props.to).toHaveProperty('path', '/to') + expect(nuxtLink({ to: { path: '/to/' } }, removeSlashOptions).props.to).toHaveProperty('path', '/to') + expect(nuxtLink({ href: '/to/' }, removeSlashOptions).props.to).toEqual('/to') + expect(nuxtLink({ to: '/to/?param=1' }, removeSlashOptions).props.to).toEqual('/to?param=1') + }) + }) }) }) diff --git a/test/basic.test.ts b/test/basic.test.ts index 10a04b7b7e..32e6b34721 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -282,6 +282,78 @@ describe('pages', () => { }) }) +describe('nuxt links', () => { + it('handles trailing slashes', async () => { + const html = await $fetch('/nuxt-link/trailing-slash') + const data: Record = {} + for (const selector of ['nuxt-link', 'router-link', 'link-with-trailing-slash', 'link-without-trailing-slash']) { + data[selector] = [] + for (const match of html.matchAll(new RegExp(`href="([^"]*)"[^>]*class="[^"]*\\b${selector}\\b`, 'g'))) { + data[selector].push(match[1]) + } + } + expect(data).toMatchInlineSnapshot(` + { + "link-with-trailing-slash": [ + "/", + "/nuxt-link/trailing-slash/", + "/nuxt-link/trailing-slash/", + "/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash/", + "/nuxt-link/trailing-slash/?with-state=true", + "/nuxt-link/trailing-slash/?without-state=true", + ], + "link-without-trailing-slash": [ + "/", + "/nuxt-link/trailing-slash", + "/nuxt-link/trailing-slash", + "/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash", + "/nuxt-link/trailing-slash?with-state=true", + "/nuxt-link/trailing-slash?without-state=true", + ], + "nuxt-link": [ + "/", + "/nuxt-link/trailing-slash", + "/nuxt-link/trailing-slash/", + "/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash", + "/nuxt-link/trailing-slash?with-state=true", + "/nuxt-link/trailing-slash?without-state=true", + ], + "router-link": [ + "/", + "/nuxt-link/trailing-slash", + "/nuxt-link/trailing-slash/", + "/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other", + "/nuxt-link/trailing-slash", + "/nuxt-link/trailing-slash?with-state=true", + "/nuxt-link/trailing-slash?without-state=true", + ], + } + `) + }) + + it('preserves route state', async () => { + const page = await createPage('/nuxt-link/trailing-slash') + await page.waitForLoadState('networkidle') + + for (const selector of ['nuxt-link', 'router-link', 'link-with-trailing-slash', 'link-without-trailing-slash']) { + await page.locator(`.${selector}[href*=with-state]`).click() + await page.waitForLoadState('networkidle') + expect(await page.getByTestId('window-state').innerText()).toContain('bar') + + await page.locator(`.${selector}[href*=without-state]`).click() + await page.waitForLoadState('networkidle') + expect(await page.getByTestId('window-state').innerText()).not.toContain('bar') + } + }) +}) + describe('head tags', () => { it('should render tags', async () => { const headHtml = await $fetch('/head') diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 479b982955..9b044be1bc 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -40,7 +40,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => { it('default server bundle size', async () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect(stats.server.totalBytes).toBeLessThan(93000) + expect(stats.server.totalBytes).toBeLessThan(94000) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect(modules.totalBytes).toBeLessThan(2722000) diff --git a/test/fixtures/basic/pages/nuxt-link/trailing-slash.vue b/test/fixtures/basic/pages/nuxt-link/trailing-slash.vue new file mode 100644 index 0000000000..a0a22fc8d1 --- /dev/null +++ b/test/fixtures/basic/pages/nuxt-link/trailing-slash.vue @@ -0,0 +1,73 @@ + + +