Skip to content

Commit bc2890e

Browse files
fix(calculator): SSR with ?g_model so share links open the right model (#431)
The TCO calculator's SSR'd HTML always rendered DeepSeek R1 (the in-memory default) because URL params were only applied client-side via the useLayoutEffect inside GlobalFilterContext. Users opening a shared link like /calculator?g_model=DeepSeek-V4-Pro&g_rundate=…&g_runid=… saw a flash of R1 until JS finished hydrating — and any preview or scraper that does not run JS saw R1 outright. Read g_model / i_seq / i_prec from searchParams in the calculator page (server component) and, when present, mount a fresh GlobalFilterProvider with the URL-derived initials inside the calculator's client tree. SSR now renders the requested model straight away. Mirrors the per-page provider pattern used by the compare/[slug] route. Fixes #430. Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Bryan Shan <Oseltamivir@users.noreply.github.com>
1 parent 8eda98d commit bc2890e

5 files changed

Lines changed: 156 additions & 4 deletions

File tree

packages/app/cypress/e2e/throughput-calculator.cy.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,5 +521,22 @@ describe('TCO Calculator', () => {
521521
cy.get('[data-testid="calculator-controls"]').should('be.visible');
522522
cy.get('[data-testid="calculator-bar-chart"] svg .bar').should('have.length.greaterThan', 0);
523523
});
524+
525+
// Regression: SSR'd HTML must reflect the URL-supplied model so share links
526+
// open straight to the right model without a flash of the default. See #430.
527+
it('?g_model= seeds the model selector before client hydration', () => {
528+
cy.request('/calculator?g_model=DeepSeek-V4-Pro').then((response) => {
529+
expect(response.body).to.contain('DeepSeek V4 Pro 1.6T');
530+
expect(response.body).not.to.contain('DeepSeek R1 0528 671B');
531+
});
532+
});
533+
534+
it('renders the URL-supplied model in the dropdown after navigating', () => {
535+
cy.window().then((win) => {
536+
win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
537+
});
538+
cy.visit('/calculator?g_model=DeepSeek-V4-Pro');
539+
cy.get('[data-testid="calc-model-selector"]').should('contain.text', 'DeepSeek V4 Pro 1.6T');
540+
});
524541
});
525542
});
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import type { Metadata } from 'next';
22

33
import ThroughputCalculatorDisplay from '@/components/calculator/ThroughputCalculatorDisplay';
4+
import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed';
45
import { tabMetadata } from '@/lib/tab-meta';
56

67
export const metadata: Metadata = tabMetadata('calculator');
78

8-
export default function CalculatorPage() {
9-
return <ThroughputCalculatorDisplay />;
9+
interface Props {
10+
searchParams: Promise<Record<string, string | string[] | undefined>>;
11+
}
12+
13+
export default async function CalculatorPage({ searchParams }: Props) {
14+
const sp = await searchParams;
15+
const seed = resolveCalculatorUrlSeed(sp);
16+
return <ThroughputCalculatorDisplay urlSeed={seed} />;
1017
}

packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { BarChart3, Table2 } from 'lucide-react';
66
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
77

88
import CalculatorTable from '@/components/calculator/CalculatorTable';
9-
import { useGlobalFilters } from '@/components/GlobalFilterContext';
9+
import type { CalculatorUrlSeed } from '@/components/calculator/url-seed';
10+
import { GlobalFilterProvider, useGlobalFilters } from '@/components/GlobalFilterContext';
1011
import { Badge } from '@/components/ui/badge';
1112
import { Card } from '@/components/ui/card';
1213
import { ChartButtons } from '@/components/ui/chart-buttons';
@@ -93,7 +94,22 @@ const CALCULATOR_VIEW_MODE_OPTIONS: SegmentedToggleOption<CalculatorViewMode>[]
9394
const CALCULATOR_MOBILE_VIEW_MODE_OPTIONS: SegmentedToggleOption<CalculatorViewMode>[] =
9495
CALCULATOR_VIEW_MODE_OPTIONS.map(({ testId: _testId, ...option }) => option);
9596

96-
export default function ThroughputCalculatorDisplay() {
97+
export default function ThroughputCalculatorDisplay({ urlSeed }: { urlSeed?: CalculatorUrlSeed }) {
98+
if (urlSeed && (urlSeed.model || urlSeed.sequence || urlSeed.precisions)) {
99+
return (
100+
<GlobalFilterProvider
101+
initialModel={urlSeed.model}
102+
initialSequence={urlSeed.sequence}
103+
initialPrecisions={urlSeed.precisions}
104+
>
105+
<ThroughputCalculatorInner />
106+
</GlobalFilterProvider>
107+
);
108+
}
109+
return <ThroughputCalculatorInner />;
110+
}
111+
112+
function ThroughputCalculatorInner() {
97113
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
98114
const handleDropdownOpenChange = (dropdownKey: string) => (isOpen: boolean) => {
99115
if (isOpen) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { resolveCalculatorUrlSeed } from './url-seed';
4+
import { Model, Precision, Sequence } from '@/lib/data-mappings';
5+
6+
describe('resolveCalculatorUrlSeed', () => {
7+
it('returns the model when g_model is a known enum value', () => {
8+
expect(resolveCalculatorUrlSeed({ g_model: 'DeepSeek-V4-Pro' })).toEqual({
9+
model: Model.DeepSeek_V4_Pro,
10+
});
11+
});
12+
13+
it('ignores unknown g_model values so SSR falls back to the default', () => {
14+
expect(resolveCalculatorUrlSeed({ g_model: 'not-a-model' })).toEqual({});
15+
});
16+
17+
it('returns the sequence when i_seq is a known enum value', () => {
18+
expect(resolveCalculatorUrlSeed({ i_seq: '1k/1k' })).toEqual({
19+
sequence: Sequence.OneK_OneK,
20+
});
21+
});
22+
23+
it('parses i_prec as a comma-separated list, dropping unknown precisions', () => {
24+
expect(resolveCalculatorUrlSeed({ i_prec: 'fp8,not-real,bf16' })).toEqual({
25+
precisions: [Precision.FP8, Precision.BF16],
26+
});
27+
});
28+
29+
it('omits precisions when none of the supplied values are known', () => {
30+
expect(resolveCalculatorUrlSeed({ i_prec: 'garbage' })).toEqual({});
31+
});
32+
33+
it('combines model, sequence, and precisions from the same URL', () => {
34+
expect(
35+
resolveCalculatorUrlSeed({
36+
g_model: 'DeepSeek-V4-Pro',
37+
i_seq: '1k/8k',
38+
i_prec: 'fp4,fp8',
39+
}),
40+
).toEqual({
41+
model: Model.DeepSeek_V4_Pro,
42+
sequence: Sequence.OneK_EightK,
43+
precisions: [Precision.FP4, Precision.FP8],
44+
});
45+
});
46+
47+
it('picks the first value when a param is repeated as an array', () => {
48+
expect(resolveCalculatorUrlSeed({ g_model: ['DeepSeek-V4-Pro', 'GLM-5'] })).toEqual({
49+
model: Model.DeepSeek_V4_Pro,
50+
});
51+
});
52+
53+
it('returns an empty seed for an empty searchParams object', () => {
54+
expect(resolveCalculatorUrlSeed({})).toEqual({});
55+
});
56+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
Model,
3+
MODEL_OPTIONS,
4+
Precision,
5+
PRECISION_OPTIONS,
6+
Sequence,
7+
SEQUENCE_OPTIONS,
8+
} from '@/lib/data-mappings';
9+
10+
export interface CalculatorUrlSeed {
11+
model?: Model;
12+
sequence?: Sequence;
13+
precisions?: string[];
14+
}
15+
16+
function pickString(value: string | string[] | undefined): string | undefined {
17+
if (typeof value === 'string') return value;
18+
if (Array.isArray(value)) return value[0];
19+
return undefined;
20+
}
21+
22+
/**
23+
* Read the URL params that the calculator can SSR with into a typed seed.
24+
* Without this, `?g_model=DeepSeek-V4-Pro` only takes effect after client
25+
* hydration runs the `useLayoutEffect` in `GlobalFilterContext` — so the
26+
* initial paint (and any preview/scraper that doesn't run JS) shows the
27+
* default model instead of the shared one.
28+
*/
29+
export function resolveCalculatorUrlSeed(
30+
sp: Record<string, string | string[] | undefined>,
31+
): CalculatorUrlSeed {
32+
const seed: CalculatorUrlSeed = {};
33+
34+
const modelParam = pickString(sp.g_model);
35+
if (modelParam && (MODEL_OPTIONS as readonly string[]).includes(modelParam)) {
36+
seed.model = modelParam as Model;
37+
}
38+
39+
const seqParam = pickString(sp.i_seq);
40+
if (seqParam && (SEQUENCE_OPTIONS as readonly string[]).includes(seqParam)) {
41+
seed.sequence = seqParam as Sequence;
42+
}
43+
44+
const precParam = pickString(sp.i_prec);
45+
if (precParam) {
46+
const precs = precParam
47+
.split(',')
48+
.filter((p) => (PRECISION_OPTIONS as readonly string[]).includes(p));
49+
if (precs.length > 0) seed.precisions = precs;
50+
}
51+
52+
return seed;
53+
}
54+
55+
// Re-export Model/Precision/Sequence for callers that already import this module.
56+
export { Model, Precision, Sequence };

0 commit comments

Comments
 (0)