lib/crux-client.js freezes CRUX_FORM_FACTORS and DEFAULT_CRUX_FORM_FACTORS (#17), but four more exported constants have exactly the same shape and were left mutable. Split out of #17 rather than folded in, because it is a sweep across three modules and one of them changes a published type.
The hazard
Each of these is exported from a public subpath and used as a default parameter value or a shared argument inside the library, so a consumer mutating one changes behaviour for every later call in the process:
| Constant |
Module |
Exported via |
Used as |
DEFAULT_PSI_STRATEGIES |
lib/psi.js:10 |
web-perf-cli/psi |
default strategies in runPsi, runPsiBatch, runPsiAuditBatch |
PSI_STRATEGIES |
lib/psi.js:9 |
web-perf-cli/psi |
validation list |
CHROME_FLAGS |
lib/lab.js:15 |
web-perf-cli/lab, root |
passed to every Chrome launch in lab.js and links.js |
DEFAULT_SKIP_AUDITS |
lib/lab.js:13 |
web-perf-cli/lab, root |
default in buildLighthouseConfig |
LAB_CATEGORIES |
lib/profiles.js:4 |
web-perf-cli/profiles |
category validation |
The PSI one is the closest analogue to what was fixed:
const { DEFAULT_PSI_STRATEGIES } = require('@hugoer/web-perf-cli/psi');
DEFAULT_PSI_STRATEGIES.push('desktop');
// every later runPsi / runPsiBatch / runPsiAuditBatch now issues an extra request per URL,
// against the 25,000/day PSI quota
CHROME_FLAGS is the sharpest: a consumer could append a flag that silently changes how every subsequent audit launches Chrome, which would quietly invalidate scores rather than just cost quota.
Do the same thing #17 did, including the typing
Freezing alone is not enough — that was the lesson from #17. DEFAULT_CRUX_FORM_FACTORS was declared string[], so it could never be passed back into the option it was the default for, and freezing it only changed the error code. Each constant here needs both halves:
Object.freeze, with an element type where the values are a closed set (DEFAULT_PSI_STRATEGIES should be readonly PsiStrategy[], not readonly string[]).
- The options that accept it widened to
readonly T[], so the exported constant can be passed to the function it is the default for.
- A
type-tests/ assertion that passes the constant into that option, plus a @ts-expect-error on a mutation. A spread compiles against both readonly and mutable shapes, so a spread alone asserts nothing.
Published type change to expect
CHROME_FLAGS is currently string[] and becomes readonly string[]. lib/links.js and lib/lab.js both pass it straight to chromeLauncher.launch({ chromeFlags }); check whether chrome-launcher's own types accept a readonly array before committing to the freeze, and spread at the call site if not.
Verification
npm run lint, npm test, npm run generate-types, npm run check-types, and the new type-tests assertions mutation-checked by reverting each freeze in turn.
lib/crux-client.jsfreezesCRUX_FORM_FACTORSandDEFAULT_CRUX_FORM_FACTORS(#17), but four more exported constants have exactly the same shape and were left mutable. Split out of #17 rather than folded in, because it is a sweep across three modules and one of them changes a published type.The hazard
Each of these is exported from a public subpath and used as a default parameter value or a shared argument inside the library, so a consumer mutating one changes behaviour for every later call in the process:
DEFAULT_PSI_STRATEGIESlib/psi.js:10web-perf-cli/psistrategiesinrunPsi,runPsiBatch,runPsiAuditBatchPSI_STRATEGIESlib/psi.js:9web-perf-cli/psiCHROME_FLAGSlib/lab.js:15web-perf-cli/lab, rootlab.jsandlinks.jsDEFAULT_SKIP_AUDITSlib/lab.js:13web-perf-cli/lab, rootbuildLighthouseConfigLAB_CATEGORIESlib/profiles.js:4web-perf-cli/profilesThe PSI one is the closest analogue to what was fixed:
CHROME_FLAGSis the sharpest: a consumer could append a flag that silently changes how every subsequent audit launches Chrome, which would quietly invalidate scores rather than just cost quota.Do the same thing #17 did, including the typing
Freezing alone is not enough — that was the lesson from #17.
DEFAULT_CRUX_FORM_FACTORSwas declaredstring[], so it could never be passed back into the option it was the default for, and freezing it only changed the error code. Each constant here needs both halves:Object.freeze, with an element type where the values are a closed set (DEFAULT_PSI_STRATEGIESshould bereadonly PsiStrategy[], notreadonly string[]).readonly T[], so the exported constant can be passed to the function it is the default for.type-tests/assertion that passes the constant into that option, plus a@ts-expect-erroron a mutation. A spread compiles against both readonly and mutable shapes, so a spread alone asserts nothing.Published type change to expect
CHROME_FLAGSis currentlystring[]and becomesreadonly string[].lib/links.jsandlib/lab.jsboth pass it straight tochromeLauncher.launch({ chromeFlags }); check whetherchrome-launcher's own types accept a readonly array before committing to the freeze, and spread at the call site if not.Verification
npm run lint,npm test,npm run generate-types,npm run check-types, and the new type-tests assertions mutation-checked by reverting each freeze in turn.