From e03f4fcdd58d1505a07fb6b5da76279fbb75e727 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Aug 2026 09:02:27 +0200 Subject: [PATCH 1/2] fix(router): reject superseded render acknowledgements --- packages/solid-router/src/Transitioner.tsx | 7 +- .../tests/transitioner-render-ack.test.tsx | 68 +++++ packages/vue-router/src/Transitioner.tsx | 8 +- .../tests/transitioner-render-ack.test.tsx | 234 ++++++++++++++++++ 4 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 packages/vue-router/tests/transitioner-render-ack.test.tsx diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index 58b3dca0762..8bd5719df8e 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -2,6 +2,7 @@ import * as Solid from 'solid-js' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' +import type { AnyRouteMatch } from '@tanstack/router-core' function getResolvedLocation(router: ReturnType) { const resolvedLocation = router.stores.resolvedLocation.get() @@ -21,9 +22,11 @@ export function Transitioner() { return null } - router.startTransition = async (fn) => { + let transitionOwner: Array | undefined + router.startTransition = async (fn, expected) => { + transitionOwner = expected await Solid.startTransition(fn) - return true + return transitionOwner === expected } // Subscribe to location changes diff --git a/packages/solid-router/tests/transitioner-render-ack.test.tsx b/packages/solid-router/tests/transitioner-render-ack.test.tsx index 03ac23a332d..634eec52daf 100644 --- a/packages/solid-router/tests/transitioner-render-ack.test.tsx +++ b/packages/solid-router/tests/transitioner-render-ack.test.tsx @@ -1,3 +1,4 @@ +import * as Solid from 'solid-js' import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' import { afterEach, expect, onTestFinished, test } from 'vitest' import { @@ -205,6 +206,73 @@ test('onRendered describes each committed navigation from the previously rendere ) }) +test('a superseded suspended generation does not emit onRendered', async () => { + const firstRenderStarted = createControlledPromise() + const firstRenderGate = createControlledPromise() + const rootRoute = createRootRoute({ + validateSearch: (search: Record) => ({ + revision: Number(search.revision ?? 0), + }), + component: () => { + const search = rootRoute.useSearch() + const [revision] = Solid.createResource( + () => search().revision, + async (nextRevision) => { + if (nextRevision === 1) { + firstRenderStarted.resolve() + await firstRenderGate + } + return nextRevision + }, + ) + return
Root revision {revision()}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/?revision=0'] }), + }) + + render(() => ) + expect(await screen.findByText('Root revision 0')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const renderedRevisions: Array = [] + const unsubscribe = router.subscribe('onRendered', (event) => { + renderedRevisions.push( + Number((event.toLocation.search as Record).revision), + ) + }) + const navigations: Array> = [] + onTestFinished(async () => { + unsubscribe() + firstRenderGate.resolve() + await Promise.allSettled(navigations) + }) + + const firstNavigation = router.navigate({ + to: '/', + search: { revision: 1 }, + }) + navigations.push(firstNavigation) + await firstRenderStarted + + expect(screen.getByText('Root revision 0')).toBeInTheDocument() + expect(screen.queryByText('Root revision 1')).not.toBeInTheDocument() + expect(renderedRevisions).toEqual([]) + + const secondNavigation = router.navigate({ + to: '/', + search: { revision: 2 }, + }) + navigations.push(secondNavigation) + await Promise.all([firstNavigation, secondNavigation]) + + expect(await screen.findByText('Root revision 2')).toBeInTheDocument() + expect(screen.queryByText('Root revision 1')).not.toBeInTheDocument() + expect(renderedRevisions).toEqual([2]) +}) + test('an older rendered destination cannot resolve a superseding navigation', async () => { const nextLoader = createControlledPromise() const rootRoute = createRootRoute({ component: () => }) diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index 96f98b7bf3e..9f177184a2b 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -2,6 +2,7 @@ import * as Vue from 'vue' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' +import type { AnyRouteMatch } from '@tanstack/router-core' export function useTransitionerSetup() { const router = useRouter() @@ -9,12 +10,13 @@ export function useTransitionerSetup() { return } - const transition = async (fn: () => void) => { + let transitionOwner: Array | undefined + router.startTransition = async (fn, expected) => { + transitionOwner = expected fn() await Vue.nextTick() - return true + return transitionOwner === expected } - router.startTransition = transition Vue.onMounted(() => { Vue.onUnmounted(router.history.subscribe(router.load)) diff --git a/packages/vue-router/tests/transitioner-render-ack.test.tsx b/packages/vue-router/tests/transitioner-render-ack.test.tsx new file mode 100644 index 00000000000..6a98aa546ad --- /dev/null +++ b/packages/vue-router/tests/transitioner-render-ack.test.tsx @@ -0,0 +1,234 @@ +import * as Vue from 'vue' +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useRouterState, +} from '../src' +import type { AnyRouter } from '../src' + +afterEach(() => { + cleanup() +}) + +test('a generation replaced before the Vue render tick does not emit onRendered', async () => { + const secondGate = createControlledPromise() + const lifecycle: Array = [] + let replacementEnabled = false + let secondNavigation: Promise | undefined + + const First = Vue.defineComponent({ + setup() { + Vue.onMounted(() => lifecycle.push('mounted:/first')) + return () =>
First
+ }, + }) + const SecondPending = Vue.defineComponent({ + setup() { + Vue.onMounted(() => lifecycle.push('mounted:pending:/second')) + return () =>
Second pending
+ }, + }) + const Second = Vue.defineComponent({ + setup() { + Vue.onMounted(() => lifecycle.push('mounted:/second')) + return () =>
Second
+ }, + }) + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: First, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: SecondPending, + beforeLoad: () => secondGate, + component: Second, + }) + const Wrap = Vue.defineComponent({ + setup(_, { slots }) { + const leafRouteId = useRouterState({ + select: (state) => state.matches.at(-1)?.routeId, + }) + Vue.watch( + leafRouteId, + (routeId) => { + if ( + replacementEnabled && + routeId === firstRoute.id && + !secondNavigation + ) { + lifecycle.push('offered:/first') + secondNavigation = router.navigate({ to: '/second' }) + lifecycle.push('navigate:/second') + } + }, + { flush: 'sync' }, + ) + return () => slots.default?.() + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + Wrap: Wrap as any, + }) + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const unsubscribers = [ + router.subscribe('onResolved', (event) => { + lifecycle.push(`onResolved:${event.toLocation.pathname}`) + }), + router.subscribe('onRendered', (event) => { + lifecycle.push(`onRendered:${event.toLocation.pathname}`) + }), + ] + replacementEnabled = true + let firstNavigation: Promise | undefined + try { + firstNavigation = router.navigate({ to: '/first' }) + + expect(await screen.findByText('Second pending')).toBeInTheDocument() + expect(secondNavigation).toBeDefined() + expect(screen.queryByText('First')).not.toBeInTheDocument() + expect(lifecycle).not.toContain('mounted:/first') + expect(lifecycle).not.toContain('onResolved:/first') + expect(lifecycle).not.toContain('onRendered:/first') + + secondGate.resolve() + await Promise.all([firstNavigation, secondNavigation!]) + expect(await screen.findByText('Second')).toBeInTheDocument() + await waitFor(() => expect(lifecycle).toContain('onRendered:/second')) + + expect(lifecycle).toContain('mounted:pending:/second') + expect(lifecycle).toContain('mounted:/second') + expect(lifecycle).toContain('onResolved:/second') + expect(lifecycle).not.toContain('mounted:/first') + expect(lifecycle).not.toContain('onResolved:/first') + expect(lifecycle).not.toContain('onRendered:/first') + } finally { + replacementEnabled = false + secondGate.resolve() + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + await Promise.allSettled( + [firstNavigation, secondNavigation].filter( + (navigation): navigation is Promise => !!navigation, + ), + ) + } +}) + +test('a rendered generation superseded before core continuation does not emit onRendered', async () => { + const secondGate = createControlledPromise() + const lifecycle: Array = [] + let secondNavigation: Promise | undefined + + const First = Vue.defineComponent({ + setup() { + Vue.onMounted(() => { + lifecycle.push('mounted:/first') + secondNavigation = router.navigate({ to: '/second' }) + lifecycle.push('navigate:/second') + }) + return () =>
First
+ }, + }) + const Second = Vue.defineComponent({ + setup() { + Vue.onMounted(() => lifecycle.push('mounted:/second')) + return () =>
Second
+ }, + }) + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: First, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: () => secondGate, + component: Second, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const unsubscribers = [ + router.subscribe('onResolved', (event) => { + lifecycle.push(`onResolved:${event.toLocation.pathname}`) + }), + router.subscribe('onRendered', (event) => { + lifecycle.push(`onRendered:${event.toLocation.pathname}`) + }), + ] + let firstNavigation: Promise | undefined + try { + firstNavigation = router.navigate({ to: '/first' }) + + expect(await screen.findByText('First')).toBeInTheDocument() + await waitFor(() => + expect(lifecycle).toEqual([ + 'mounted:/first', + 'navigate:/second', + ]), + ) + expect(secondNavigation).toBeDefined() + expect(lifecycle).not.toContain('onResolved:/first') + + secondGate.resolve() + await Promise.all([firstNavigation, secondNavigation!]) + expect(await screen.findByText('Second')).toBeInTheDocument() + await waitFor(() => + expect(lifecycle).toEqual([ + 'mounted:/first', + 'navigate:/second', + 'mounted:/second', + 'onResolved:/second', + 'onRendered:/second', + ]), + ) + } finally { + secondGate.resolve() + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + await Promise.allSettled( + [firstNavigation, secondNavigation].filter( + (navigation): navigation is Promise => !!navigation, + ), + ) + } +}) From 1e9ea46041c049e3c315a0ac80c678dc617e133b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:05:41 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- packages/vue-router/tests/transitioner-render-ack.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/vue-router/tests/transitioner-render-ack.test.tsx b/packages/vue-router/tests/transitioner-render-ack.test.tsx index 6a98aa546ad..a69ba40605b 100644 --- a/packages/vue-router/tests/transitioner-render-ack.test.tsx +++ b/packages/vue-router/tests/transitioner-render-ack.test.tsx @@ -200,10 +200,7 @@ test('a rendered generation superseded before core continuation does not emit on expect(await screen.findByText('First')).toBeInTheDocument() await waitFor(() => - expect(lifecycle).toEqual([ - 'mounted:/first', - 'navigate:/second', - ]), + expect(lifecycle).toEqual(['mounted:/first', 'navigate:/second']), ) expect(secondNavigation).toBeDefined() expect(lifecycle).not.toContain('onResolved:/first')