diff --git a/packages/app/cypress/e2e/throughput-calculator.cy.ts b/packages/app/cypress/e2e/throughput-calculator.cy.ts index 3cdeaf68..362e94c0 100644 --- a/packages/app/cypress/e2e/throughput-calculator.cy.ts +++ b/packages/app/cypress/e2e/throughput-calculator.cy.ts @@ -521,5 +521,22 @@ describe('TCO Calculator', () => { cy.get('[data-testid="calculator-controls"]').should('be.visible'); cy.get('[data-testid="calculator-bar-chart"] svg .bar').should('have.length.greaterThan', 0); }); + + // Regression: SSR'd HTML must reflect the URL-supplied model so share links + // open straight to the right model without a flash of the default. See #430. + it('?g_model= seeds the model selector before client hydration', () => { + cy.request('/calculator?g_model=DeepSeek-V4-Pro').then((response) => { + expect(response.body).to.contain('DeepSeek V4 Pro 1.6T'); + expect(response.body).not.to.contain('DeepSeek R1 0528 671B'); + }); + }); + + it('renders the URL-supplied model in the dropdown after navigating', () => { + cy.window().then((win) => { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }); + cy.visit('/calculator?g_model=DeepSeek-V4-Pro'); + cy.get('[data-testid="calc-model-selector"]').should('contain.text', 'DeepSeek V4 Pro 1.6T'); + }); }); }); diff --git a/packages/app/src/app/(dashboard)/calculator/page.tsx b/packages/app/src/app/(dashboard)/calculator/page.tsx index 0bd2ead4..7b11ff34 100644 --- a/packages/app/src/app/(dashboard)/calculator/page.tsx +++ b/packages/app/src/app/(dashboard)/calculator/page.tsx @@ -1,10 +1,17 @@ import type { Metadata } from 'next'; import ThroughputCalculatorDisplay from '@/components/calculator/ThroughputCalculatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; import { tabMetadata } from '@/lib/tab-meta'; export const metadata: Metadata = tabMetadata('calculator'); -export default function CalculatorPage() { - return ; +interface Props { + searchParams: Promise>; +} + +export default async function CalculatorPage({ searchParams }: Props) { + const sp = await searchParams; + const seed = resolveCalculatorUrlSeed(sp); + return ; } diff --git a/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx b/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx index 4586dab4..521e45b2 100644 --- a/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx +++ b/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx @@ -6,7 +6,8 @@ import { BarChart3, Table2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import CalculatorTable from '@/components/calculator/CalculatorTable'; -import { useGlobalFilters } from '@/components/GlobalFilterContext'; +import type { CalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { GlobalFilterProvider, useGlobalFilters } from '@/components/GlobalFilterContext'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { ChartButtons } from '@/components/ui/chart-buttons'; @@ -93,7 +94,22 @@ const CALCULATOR_VIEW_MODE_OPTIONS: SegmentedToggleOption[] const CALCULATOR_MOBILE_VIEW_MODE_OPTIONS: SegmentedToggleOption[] = CALCULATOR_VIEW_MODE_OPTIONS.map(({ testId: _testId, ...option }) => option); -export default function ThroughputCalculatorDisplay() { +export default function ThroughputCalculatorDisplay({ urlSeed }: { urlSeed?: CalculatorUrlSeed }) { + if (urlSeed && (urlSeed.model || urlSeed.sequence || urlSeed.precisions)) { + return ( + + + + ); + } + return ; +} + +function ThroughputCalculatorInner() { const [openDropdown, setOpenDropdown] = useState(null); const handleDropdownOpenChange = (dropdownKey: string) => (isOpen: boolean) => { if (isOpen) { diff --git a/packages/app/src/components/calculator/url-seed.test.ts b/packages/app/src/components/calculator/url-seed.test.ts new file mode 100644 index 00000000..8d590b6a --- /dev/null +++ b/packages/app/src/components/calculator/url-seed.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCalculatorUrlSeed } from './url-seed'; +import { Model, Precision, Sequence } from '@/lib/data-mappings'; + +describe('resolveCalculatorUrlSeed', () => { + it('returns the model when g_model is a known enum value', () => { + expect(resolveCalculatorUrlSeed({ g_model: 'DeepSeek-V4-Pro' })).toEqual({ + model: Model.DeepSeek_V4_Pro, + }); + }); + + it('ignores unknown g_model values so SSR falls back to the default', () => { + expect(resolveCalculatorUrlSeed({ g_model: 'not-a-model' })).toEqual({}); + }); + + it('returns the sequence when i_seq is a known enum value', () => { + expect(resolveCalculatorUrlSeed({ i_seq: '1k/1k' })).toEqual({ + sequence: Sequence.OneK_OneK, + }); + }); + + it('parses i_prec as a comma-separated list, dropping unknown precisions', () => { + expect(resolveCalculatorUrlSeed({ i_prec: 'fp8,not-real,bf16' })).toEqual({ + precisions: [Precision.FP8, Precision.BF16], + }); + }); + + it('omits precisions when none of the supplied values are known', () => { + expect(resolveCalculatorUrlSeed({ i_prec: 'garbage' })).toEqual({}); + }); + + it('combines model, sequence, and precisions from the same URL', () => { + expect( + resolveCalculatorUrlSeed({ + g_model: 'DeepSeek-V4-Pro', + i_seq: '1k/8k', + i_prec: 'fp4,fp8', + }), + ).toEqual({ + model: Model.DeepSeek_V4_Pro, + sequence: Sequence.OneK_EightK, + precisions: [Precision.FP4, Precision.FP8], + }); + }); + + it('picks the first value when a param is repeated as an array', () => { + expect(resolveCalculatorUrlSeed({ g_model: ['DeepSeek-V4-Pro', 'GLM-5'] })).toEqual({ + model: Model.DeepSeek_V4_Pro, + }); + }); + + it('returns an empty seed for an empty searchParams object', () => { + expect(resolveCalculatorUrlSeed({})).toEqual({}); + }); +}); diff --git a/packages/app/src/components/calculator/url-seed.ts b/packages/app/src/components/calculator/url-seed.ts new file mode 100644 index 00000000..c41f74b2 --- /dev/null +++ b/packages/app/src/components/calculator/url-seed.ts @@ -0,0 +1,56 @@ +import { + Model, + MODEL_OPTIONS, + Precision, + PRECISION_OPTIONS, + Sequence, + SEQUENCE_OPTIONS, +} from '@/lib/data-mappings'; + +export interface CalculatorUrlSeed { + model?: Model; + sequence?: Sequence; + precisions?: string[]; +} + +function pickString(value: string | string[] | undefined): string | undefined { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value[0]; + return undefined; +} + +/** + * Read the URL params that the calculator can SSR with into a typed seed. + * Without this, `?g_model=DeepSeek-V4-Pro` only takes effect after client + * hydration runs the `useLayoutEffect` in `GlobalFilterContext` — so the + * initial paint (and any preview/scraper that doesn't run JS) shows the + * default model instead of the shared one. + */ +export function resolveCalculatorUrlSeed( + sp: Record, +): CalculatorUrlSeed { + const seed: CalculatorUrlSeed = {}; + + const modelParam = pickString(sp.g_model); + if (modelParam && (MODEL_OPTIONS as readonly string[]).includes(modelParam)) { + seed.model = modelParam as Model; + } + + const seqParam = pickString(sp.i_seq); + if (seqParam && (SEQUENCE_OPTIONS as readonly string[]).includes(seqParam)) { + seed.sequence = seqParam as Sequence; + } + + const precParam = pickString(sp.i_prec); + if (precParam) { + const precs = precParam + .split(',') + .filter((p) => (PRECISION_OPTIONS as readonly string[]).includes(p)); + if (precs.length > 0) seed.precisions = precs; + } + + return seed; +} + +// Re-export Model/Precision/Sequence for callers that already import this module. +export { Model, Precision, Sequence };