diff --git a/.changeset/cyan-beers-kneel.md b/.changeset/cyan-beers-kneel.md new file mode 100644 index 0000000000..1bdbfe6ded --- /dev/null +++ b/.changeset/cyan-beers-kneel.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-router': patch +--- + +Remove an unnecessary match Fragment to reduce VNode allocation and transient JIT compilation memory while preserving route content and scroll restoration. diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 552dcc2e30..de1bcfa67a 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -129,11 +129,9 @@ export const Match = Vue.defineComponent({ return Vue.h(Vue.Fragment, null, [ content, - Vue.h(Vue.Fragment, null, [ - (isServer ?? router.isServer) && router.options.scrollRestoration - ? Vue.h(ScrollRestoration) - : null, - ]), + (isServer ?? router.isServer) && router.options.scrollRestoration + ? Vue.h(ScrollRestoration) + : null, ]) } diff --git a/packages/vue-router/tests/Match.test.tsx b/packages/vue-router/tests/Match.test.tsx new file mode 100644 index 0000000000..0d2e7b6f15 --- /dev/null +++ b/packages/vue-router/tests/Match.test.tsx @@ -0,0 +1,107 @@ +import * as Vue from 'vue' +import { renderToString } from 'vue/server-renderer' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +function createTestRouter( + scrollRestoration: boolean | (() => boolean), + isServer = false, +) { + const revision = Vue.ref(0) + const mounted = vi.fn() + const Child = Vue.defineComponent({ + setup() { + const count = Vue.ref(0) + Vue.onMounted(mounted) + return () => + }, + }) + const Shell = Vue.defineComponent({ + setup(_, { slots }) { + return () => ( +
+ {slots.default?.()} +
+ ) + }, + }) + const root = createRootRoute({ + shellComponent: Shell, + component: () => ( +
+ +
+ ), + }) + const child = createRoute({ + getParentRoute: () => root, + path: '/child', + component: Child, + }) + const router = createRouter({ + routeTree: root.addChildren([child]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer, + scrollRestoration, + ssr: { nonce: 'test-nonce' }, + }) + return { router, revision, mounted } +} + +test.each([false, true])( + 'preserves root child state through shell updates (scroll restoration: %s)', + async (scrollRestoration) => { + const { router, revision, mounted } = createTestRouter(scrollRestoration) + const app = render() + const button = await app.findByRole('button') + + await fireEvent.click(button) + revision.value++ + router.update({ scrollRestoration: !scrollRestoration }) + await router.invalidate() + await Vue.nextTick() + + expect(app.getByRole('button')).toBe(button) + expect(button).toHaveTextContent('1') + expect(mounted).toHaveBeenCalledOnce() + expect(app.getByTestId('shell')).toHaveAttribute('data-revision', '1') + }, +) + +test.each([ + ['disabled', false, false], + ['enabled', true, true], + ['skipped by selector', (): boolean => false, false], + ['enabled by selector', (): boolean => true, true], +] as const)( + 'renders root child content and the restoration script on the server (%s)', + async (_, scrollRestoration, hasScript) => { + const { router } = createTestRouter(scrollRestoration, true) + await router.load() + const html = await renderToString( + Vue.createSSRApp(() => ), + ) + const container = document.createElement('div') + container.innerHTML = html + + expect(container.querySelector('main > button')).toHaveTextContent('0') + const scripts = container.querySelectorAll('main > script') + expect(scripts).toHaveLength(hasScript ? 1 : 0) + if (hasScript) { + expect(scripts[0]).toHaveAttribute('nonce', 'test-nonce') + expect(scripts[0]?.textContent).toContain( + 'document.currentScript.remove()', + ) + } + }, +)