diff --git a/.changeset/cold-mirrors-read.md b/.changeset/cold-mirrors-read.md new file mode 100644 index 00000000000..f1a23d6b7c4 --- /dev/null +++ b/.changeset/cold-mirrors-read.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Reduce SSR Link rendering work by using fast-property null-prototype records on the server while preserving the existing client allocation path. diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 9b1b3c411a8..8d34ce952ce 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -218,7 +218,9 @@ export function hasKeys(obj: Record) { return false } -export const createNull = () => Object.create(null) +// SSR copies benefit from fast properties; client structural sharing does not. +export const createNull = () => + isServer ? Object.setPrototypeOf({}, null) : Object.create(null) export const nullReplaceEqualDeep: typeof replaceEqualDeep = (prev, next) => replaceEqualDeep(prev, next, createNull) diff --git a/packages/router-core/tests/null-records.bench.ts b/packages/router-core/tests/null-records.bench.ts new file mode 100644 index 00000000000..f60529372ef --- /dev/null +++ b/packages/router-core/tests/null-records.bench.ts @@ -0,0 +1,82 @@ +import { afterAll, bench, describe, expect } from 'vitest' +import { hasKeys } from '../src/utils' + +type RecordFactory = () => Record + +const dictionary: RecordFactory = () => Object.create(null) +const fast: RecordFactory = () => Object.setPrototypeOf({}, null) +const iterations = 128 +const benchOptions = { + time: 500, + warmupTime: 100, + throws: true, +} + +if (process.env.TSR_LINK_PERF === '1') { + for (const scenario of [ + { name: 'empty records', size: 0, layouts: 1 }, + { name: 'one field', size: 1, layouts: 1 }, + { name: 'eight fields', size: 8, layouts: 1 }, + { name: '64 fields', size: 64, layouts: 1 }, + { name: '256 fields', size: 256, layouts: 1 }, + { name: 'eight changing field names', size: 8, layouts: 32 }, + ]) { + describe(`null records: ${scenario.name}`, () => { + for (const variant of [ + { + name: 'dictionary source / dictionary target', + source: dictionary, + target: dictionary, + }, + { + name: 'dictionary source / fast target', + source: dictionary, + target: fast, + }, + { + name: 'fast source / dictionary target', + source: fast, + target: dictionary, + }, + { name: 'fast source / fast target', source: fast, target: fast }, + ]) { + const sources = Array.from( + { length: scenario.layouts }, + (_, layout) => { + const source = variant.source() + for (let index = 0; index < scenario.size; index++) { + source[`field_${layout}_${index}`] = `value-${index}` + } + return source + }, + ) + const records: Array> = [] + let nonempty = 0 + function run() { + nonempty = 0 + for (let index = 0; index < iterations; index++) { + const result = Object.assign( + variant.target(), + sources[index % sources.length], + ) + records[index] = result + if (hasKeys(result)) { + nonempty++ + } + } + } + function check() { + expect(nonempty).toBe(scenario.size ? iterations : 0) + for (let index = 0; index < records.length; index++) { + expect(Object.getPrototypeOf(records[index])).toBeNull() + expect(records[index]).toEqual(sources[index % sources.length]) + } + } + run() + check() + bench(variant.name, run, benchOptions) + afterAll(check) + } + }) + } +} diff --git a/packages/router-core/tests/null-records.test.ts b/packages/router-core/tests/null-records.test.ts new file mode 100644 index 00000000000..bf039553abb --- /dev/null +++ b/packages/router-core/tests/null-records.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test, vi } from 'vitest' +import { createNull } from '../src/utils' + +const environment = vi.hoisted(() => ({ server: false })) +vi.mock('@tanstack/router-core/isServer', () => ({ + get isServer() { + return environment.server + }, +})) + +describe.each([false, true])('null records (server: %s)', (server) => { + test('returns fresh extensible records without inherited properties', () => { + environment.server = server + const first = createNull() + const second = createNull() + expect(Object.getPrototypeOf(first)).toBeNull() + expect(Reflect.ownKeys(first)).toEqual([]) + expect(Object.isExtensible(first)).toBe(true) + expect(first).not.toBe(second) + expect('constructor' in first).toBe(false) + expect('__proto__' in first).toBe(false) + }) + + test('copies special keys and symbols without invoking prototype setters', () => { + environment.server = server + const symbol = Symbol('param') + const source = JSON.parse( + '{"__proto__":{"polluted":true},"constructor":"value","id":"item"}', + ) + source[symbol] = 'symbol value' + const result = Object.assign(createNull(), source) + expect(Object.getPrototypeOf(result)).toBeNull() + expect(Reflect.ownKeys(result)).toEqual(Reflect.ownKeys(source)) + expect(Object.getOwnPropertyDescriptors(result)).toEqual( + Object.getOwnPropertyDescriptors(source), + ) + expect(result.polluted).toBeUndefined() + }) +})