diff --git a/.changeset/green-areas-shine.md b/.changeset/green-areas-shine.md new file mode 100644 index 00000000000..6d0b5ecfd35 --- /dev/null +++ b/.changeset/green-areas-shine.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Prevent constructor fields in search parameters from crashing router initialization or client navigation, including null values and objects that shadow hasOwnProperty. diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 9b1b3c411a8..6f63edaa0ab 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -337,13 +337,13 @@ export function isPlainObject(o: any) { } // If has modified prototype - const prot = ctor.prototype + const prot = ctor?.prototype if (!hasObjectPrototype(prot)) { return false } // If constructor does not have an Object-specific method - if (!prot.hasOwnProperty('isPrototypeOf')) { + if (!hasOwn.call(prot, 'isPrototypeOf')) { return false } diff --git a/packages/router-core/tests/search-constructor.test.ts b/packages/router-core/tests/search-constructor.test.ts new file mode 100644 index 00000000000..23fac70efca --- /dev/null +++ b/packages/router-core/tests/search-constructor.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('search parameters with constructor data', () => { + test.each([ + ['null', null], + ['zero', 0], + ['string', 'model'], + ['object', { prototype: { hasOwnProperty: null } }], + ])('initializes and loads with a %s constructor field', async (_, value) => { + const rootRoute = new BaseRootRoute({}) + const loader = vi.fn(() => 'loaded') + const validateSearch = vi.fn(() => ({})) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch, + loader, + }) + const history = createMemoryHistory({ + initialEntries: [ + `/?constructor=${encodeURIComponent(JSON.stringify(value))}`, + ], + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history, + isServer: false, + search: { strict: true }, + }) + + await router.load() + + expect(loader).toHaveBeenCalledOnce() + expect(validateSearch).toHaveBeenCalled() + expect(router.state.matches.at(-1)?.loaderData).toBe('loaded') + }) + + test('preserves constructor data during client navigation', async () => { + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (search: Record) => search, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory(), + isServer: false, + }) + await router.load() + + await router.navigate({ to: '/', search: { constructor: null } }) + + expect(router.state.location.search.constructor).toBeNull() + expect(router.state.location.href).toBe('/?constructor=null') + await router.navigate({ to: '/', search: { constructor: 'model' } }) + expect(router.state.location.search.constructor).toBe('model') + }) +})