Simulated throttling for soft navigation steps: land it upstream or bridge it in the package

Abierto
#23 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
5/5
Tiempo estimado
Más de una semana
Aptitud para principiantes
15/100
Tipo de issue
Nueva funcionalidad
Claridad
Necesita aclaración
Estado de actividad
Activo
Stack tecnológico
devtools, typescript

Línea de trabajo

Start with docs/simulated-throttling.md, then build the package (pnpm nx build lighthouse-soft-navigation) and run the two probe scripts in tmp/probe/ to reproduce the simulate-versus-devtools gap. The open work is a design decision (upstream issue versus package bridge options A-D), not a code change a newcomer can finish. Done means that decision is recorded and the accuracy bar is agreed.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

feature

Problem

A soft navigation step only works with throttlingMethod: 'devtools' or 'provided'. With simulate, which is Lighthouse's default and what PageSpeed Insights uses, seven audits error with rootRequest not found and the step has no Performance score.

That leaves a flow with two bad choices:

  • set the throttling method on every soft navigation step, so page loads are simulated and soft navigations are measured under applied throttling, and the two don't compare; or
  • set devtools for the whole flow, so page loads get slower, vary more and stop comparing with PageSpeed Insights.

What is known

Simulated throttling for soft navigations has the details. In short:

  • Lantern, the simulator, roots its graph at the document request. A soft navigation has none, so the graph is never built.

  • Giving the graph a root is not enough. On the e2e fixture app, with a synthetic root request:

    FCP LCP
    Recorded without throttling 54–55 ms 206–207 ms
    DevTools throttling, same route 574–578 ms 1146–1151 ms
    Lantern with a synthetic root 9–10 ms 659–660 ms
  • Three rules in Lantern cause that: the FCP graph only keeps render-blocking requests and a fetch is never one, a request that waits for the response of another is simulated in parallel with it, and every origin is charged a new connection.

  • Lantern's source is in the Chrome DevTools frontend (front_end/models/trace/lantern) and reaches Lighthouse through @paulirish/trace_engine. Changes are reviewed on Chromium's Gerrit. Lantern issues are filed on GoogleChrome/lighthouse.

  • Nobody has filed this upstream. lighthouse#14573 and lighthouse#17075 don't mention simulation; #17075 switches soft navigations from simulate to devtools.

To decide

1. How to land it upstream
  • Where to raise it: a new issue on GoogleChrome/lighthouse that references #14573 and #17075, or as part of the #17075 discussion.
  • Whether the Lighthouse team wants simulated soft navigations at all, or prefers the simulate → devtools override for good.
  • The order of the changes. The doc lists seven. The graph root has to come first, and it can't ship alone because it turns the error into wrong numbers.
  • Who collects the validation traces. Lighthouse's golden Lantern dataset (lighthouse#17411) has page loads only. Soft navigations need a flow per site: a URL and the interaction that navigates.
2. Whether the package bridges it until then
Option What the package does Result Cost
A. Nothing (today) Requires devtools or provided, warns under simulate Correct numbers, mixed methods in one flow None
B. Synthetic root Extends the DevtoolsLog gatherer to add a cached document request at SoftNavigationStart Audits compute. FCP comes out at 10 ms on the fixture, where DevTools throttling measures 576 ms Small, but not shippable alone. The request would also show up in network-requests
C. Own graph rules B, plus the seven metric audits replaced for soft navigation steps by ones that pick their own FCP/LCP graphs and add the missing edges Simulated numbers that no dataset has validated A copy of Lantern's rules to maintain. Warm connections are inside ConnectionPool and can't be reached without patching it
D. Observed under simulate Wraps the metric audits so a soft navigation step under simulate is computed as provided and warns Honest but unthrottled numbers, far lower than the simulated page loads next to them Small. Scores look better than they are

C and D are untested. B is what the measurement above used.

3. What "good enough" means for a bridge

If the package ships C, it needs an accuracy bar: which apps, compared against which reference (DevTools throttling is the obvious one), and how far off is acceptable.

Tasks

  • Agree on raising it upstream, and where
  • Prototype the graph root and the soft navigation paint graph against the fixture app
  • Record soft navigations of a few real apps, without throttling and with DevTools throttling
  • Decide between A, C and D for the package

Reproducing the measurement

Build the package (pnpm nx build lighthouse-soft-navigation), save both scripts in tmp/probe/ and run them from the workspace root. Lighthouse 13.6.0, @paulirish/trace_engine 0.0.67, Chrome 157.

tmp/probe/probe.mjs: runs the flow and saves its artifacts
// Probe: what happens to a soft navigation step under the default simulated throttling.
import { writeFileSync } from 'node:fs';
import { startFlow } from 'lighthouse';
import puppeteer from 'puppeteer';
import { softNavigationConfig } from '../../packages/lighthouse-soft-navigation/dist/index.js';
import { startAppServer } from '../../packages/lighthouse-soft-navigation-e2e/fixtures/app-server.mjs';

const out = new URL('.', import.meta.url).pathname;
const server = await startAppServer();
const browser = await puppeteer.launch({
  headless: true,
  args: ['--no-sandbox', '--disable-setuid-sandbox'],
});

try {
  console.log('chrome', await browser.version());
  const page = await browser.newPage();
  const flow = await startFlow(page, { config: softNavigationConfig() });

  await flow.navigate(`${server.origin}/`);

  // Soft navigation with the default throttling method (simulate).
  await flow.navigate(async () => {
    await page.click('#products-link');
  });

  // Soft navigation with DevTools throttling, for comparison.
  await flow.navigate(
    async () => {
      await page.click('#home-link');
    },
    { throttlingMethod: 'devtools' },
  );

  // The first soft navigation again, with DevTools throttling: the reference for the simulation.
  await flow.navigate(
    async () => {
      await page.click('#products-link');
    },
    { throttlingMethod: 'devtools' },
  );

  const result = await flow.createFlowResult();
  writeFileSync(`${out}flow.json`, JSON.stringify(result));
  writeFileSync(
    `${out}artifacts.json`,
    JSON.stringify(flow.createArtifactsJson()),
  );

  for (const [i, step] of result.steps.entries()) {
    const { lhr } = step;
    console.log(
      `\n== step ${i}: ${step.name} | ${lhr.gatherMode} | ${lhr.configSettings.throttlingMethod} | perf ${lhr.categories.performance?.score}`,
    );
    for (const w of lhr.runWarnings) console.log('  warning:', w);
    const errors = new Map();
    for (const a of Object.values(lhr.audits)) {
      if (a.scoreDisplayMode !== 'error') continue;
      const list = errors.get(a.errorMessage) ?? [];
      list.push(a.id);
      errors.set(a.errorMessage, list);
    }
    for (const [message, ids] of errors)
      console.log(`  error "${message}": ${ids.join(', ')}`);
    for (const id of [
      'first-contentful-paint',
      'largest-contentful-paint',
      'total-blocking-time',
      'speed-index',
      'interactive',
    ]) {
      const a = lhr.audits[id];
      console.log(`  ${id}: ${a?.scoreDisplayMode} ${a?.numericValue ?? ''}`);
    }
  }
} finally {
  await browser.close();
  await server.close();
}
tmp/probe/lantern-probe.mjs: runs Lantern on the saved step with a synthetic root
// Probe: what Lantern returns for a soft navigation once its graph has a root.
// The root is a synthetic, cached document request at SoftNavigationStart.
import { readFileSync } from 'node:fs';
import { LanternFirstContentfulPaint } from 'lighthouse/core/computed/metrics/lantern-first-contentful-paint.js';
import { LanternLargestContentfulPaint } from 'lighthouse/core/computed/metrics/lantern-largest-contentful-paint.js';
import { LanternInteractive } from 'lighthouse/core/computed/metrics/lantern-interactive.js';
import { LanternTotalBlockingTime } from 'lighthouse/core/computed/metrics/lantern-total-blocking-time.js';
import { LanternSpeedIndex } from 'lighthouse/core/computed/metrics/lantern-speed-index.js';

const dir = new URL('.', import.meta.url).pathname;
const { gatherSteps } = JSON.parse(readFileSync(`${dir}artifacts.json`, 'utf8'));
const flow = JSON.parse(readFileSync(`${dir}flow.json`, 'utf8'));
const stepIndex = Number(process.argv[2] ?? 1);
const { artifacts } = gatherSteps[stepIndex];

const start = artifacts.Trace.traceEvents.find(
  (e) => e.name === 'SoftNavigationStart',
);
const frameId = artifacts.DevtoolsLog.find(
  (e) => e.method === 'Network.requestWillBeSent',
).params.frameId;
const url = artifacts.URL.mainDocumentUrl;
const timestamp = start.ts / 1_000_000;
const requestId = 'synthetic-soft-navigation';

const synthetic = [
  {
    method: 'Network.requestWillBeSent',
    params: {
      requestId,
      loaderId: requestId,
      documentURL: url,
      request: {
        url,
        method: 'GET',
        headers: {},
        initialPriority: 'VeryHigh',
        mixedContentType: 'none',
        referrerPolicy: 'strict-origin-when-cross-origin',
      },
      timestamp,
      wallTime: 0,
      initiator: { type: 'other' },
      type: 'Document',
      frameId,
    },
  },
  {
    method: 'Network.responseReceived',
    params: {
      requestId,
      loaderId: requestId,
      timestamp,
      type: 'Document',
      frameId,
      response: {
        url,
        status: 200,
        statusText: 'OK',
        headers: {},
        mimeType: 'text/html',
        connectionReused: false,
        connectionId: 0,
        fromDiskCache: true,
        encodedDataLength: 0,
        protocol: 'http/1.1',
        securityState: 'neutral',
      },
    },
  },
  {
    method: 'Network.loadingFinished',
    params: { requestId, timestamp, encodedDataLength: 0 },
  },
];

const data = {
  trace: artifacts.Trace,
  devtoolsLog: process.env.NO_ROOT
    ? artifacts.DevtoolsLog
    : [...synthetic, ...artifacts.DevtoolsLog],
  gatherContext: artifacts.GatherContext,
  settings: { ...artifacts.settings, throttlingMethod: 'simulate' },
  URL: artifacts.URL,
  SourceMaps: artifacts.SourceMaps,
  HostDPR: artifacts.HostDPR,
  simulator: null,
};
const context = { computedCache: new Map() };

const describe = (graph) => {
  const nodes = [];
  graph.traverse((node) => {
    nodes.push(
      node.type === 'network'
        ? `net ${new URL(node.request.url).pathname}${node.request.fromDiskCache ? ' (synthetic root)' : ''}`
        : `cpu ${(node.duration / 1000).toFixed(1)}ms`,
    );
  });
  return nodes;
};

const lhr = flow.steps[stepIndex].lhr;
console.log(`step ${stepIndex}: ${lhr.finalDisplayedUrl}`);
console.log(
  'observed, unthrottled (ms from SoftNavigationStart):',
  JSON.stringify(
    Object.fromEntries(
      ['firstContentfulPaint', 'largestContentfulPaint'].map((k) => {
        const name =
          k === 'firstContentfulPaint'
            ? 'firstContentfulPaint'
            : 'largestContentfulPaint::Candidate';
        const events = artifacts.Trace.traceEvents.filter(
          (e) => e.name === name,
        );
        return [k, (events.at(-1).ts - start.ts) / 1000];
      }),
    ),
  ),
);

try {
  const fcp = await LanternFirstContentfulPaint.request(data, context);
  const lcp = await LanternLargestContentfulPaint.request(data, context);
  const tti = await LanternInteractive.request(data, context);
  const tbt = await LanternTotalBlockingTime.request(data, context);
  const si = await LanternSpeedIndex.request(data, context);

  for (const [name, m] of Object.entries({ fcp, lcp, tti, tbt, si })) {
    console.log(
      `${name}: ${m.timing.toFixed(1)} ms (optimistic ${m.optimisticEstimate.timeInMs.toFixed(1)}, pessimistic ${m.pessimisticEstimate.timeInMs.toFixed(1)})`,
    );
  }
  console.log('fcp optimistic graph :', describe(fcp.optimisticGraph));
  console.log('fcp pessimistic graph:', describe(fcp.pessimisticGraph));
  console.log('lcp optimistic graph :', describe(lcp.optimisticGraph));
  console.log('lcp pessimistic graph:', describe(lcp.pessimisticGraph));
  for (const [node, t] of lcp.pessimisticEstimate.nodeTimings) {
    console.log(
      `  lcp pessimistic timing: ${node.type === 'network' ? new URL(node.request.url).pathname : 'cpu'} ${t.startTime.toFixed(1)} → ${t.endTime.toFixed(1)}`,
    );
  }
} catch (error) {
  console.log('error:', error.message);
}
node tmp/probe/probe.mjs            # step 1 errors under simulate, step 3 is the DevTools reference
node tmp/probe/lantern-probe.mjs 1  # Lantern on step 1, with the synthetic root
NO_ROOT=1 node tmp/probe/lantern-probe.mjs 1  # without it: rootRequest not found
Lenguaje dominante
TypeScript
Estrellas
0
Forks
0
Merge medio
4 h 18 min
PR fusionados (30 d)
12

Preparar el entorno

Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Issues similares

Más issues de TypeScript

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.