From 5b8e0fb7b3fb600ac7ba9011511b7884c9f0ad3b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 18 Aug 2026 15:48:24 +1000 Subject: [PATCH] perf: keep valibot out of production client bundles Two runtime call sites forced the valibot schema runtime into every production client bundle, worth 12 KB raw (3.7 KB gzip) on the basic fixture. - `speedcurve.ts` derived `LUX_USER_CONFIG_KEYS` from `SpeedCurveOptions.entries` at module scope, which pinned the schema past the `import.meta.dev` guard. The keys are now listed, with type assertions that fail to compile if they drift from the schema. - `_gcm-consent.ts` validated consent state with `safeParse` on a strict schema. It now uses a hand written check with the same warnings. Every other registry schema was already dev only and treeshakes. --- .../src/runtime/registry/_gcm-consent.ts | 58 +++++++++++++++---- .../script/src/runtime/registry/speedcurve.ts | 27 +++++++-- test/unit/gcm-consent.test.ts | 53 +++++++++++++++++ 3 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 test/unit/gcm-consent.test.ts diff --git a/packages/script/src/runtime/registry/_gcm-consent.ts b/packages/script/src/runtime/registry/_gcm-consent.ts index 15c74b539..9a88e1b9a 100644 --- a/packages/script/src/runtime/registry/_gcm-consent.ts +++ b/packages/script/src/runtime/registry/_gcm-consent.ts @@ -1,14 +1,48 @@ import type { ConsentState, GcmConsentApi, UseScriptContext } from '../types' -import { safeParse, strictObject } from 'valibot' import { logger } from '../logger' -import { gcmConsentState } from './schemas' export type { GcmConsentApi } -// Strict variant rebuilt from the lenient schema's entries — same shape, but -// unknown keys produce issues so we can warn on typos without breaking the -// lenient `defaultConsent` schema parse used at build time. -const gcmConsentStateStrict = strictObject(gcmConsentState.entries) +// GCMv2 consent categories. Every entry takes `granted` or `denied`. +const CONSENT_CATEGORIES = [ + 'ad_storage', + 'ad_user_data', + 'ad_personalization', + 'analytics_storage', + 'functionality_storage', + 'personalization_storage', + 'security_storage', +] as const satisfies readonly (keyof ConsentState)[] + +const CONSENT_CATEGORY_KEYS: ReadonlySet = new Set(CONSENT_CATEGORIES) + +// Fails to compile if `ConsentState` grows a key this validator does not know about. +type AssertNever = T +type _ConsentKeysChecked = AssertNever> + +/** + * Describe what is wrong with one consent entry, or return `null` when it is valid. + * The canonical schema stays lenient so unknown keys pass a schema parse; here we + * reject them, because an unknown key is almost always a typo the user wants to see. + */ +function describeConsentIssue(key: string, value: unknown): string | null { + if (CONSENT_CATEGORY_KEYS.has(key)) { + return value === 'granted' || value === 'denied' + ? null + : `"${key}" must be "granted" or "denied", received ${JSON.stringify(value)}` + } + if (key === 'wait_for_update') { + return typeof value === 'number' && Number.isFinite(value) + ? null + : `"wait_for_update" must be a number, received ${JSON.stringify(value)}` + } + if (key === 'region') { + return Array.isArray(value) && value.every(entry => typeof entry === 'string') + ? null + : `"region" must be an array of strings, received ${JSON.stringify(value)}` + } + return `unknown key "${key}"` +} /** * GCMv2 consent contract returned by registry scripts (GA, GTM, future Google Ads, …). @@ -21,11 +55,15 @@ export interface GcmConsentContract { /** Validate a partial GCMv2 consent state. Logs each issue via the registry-scoped logger. */ export function validateConsentState(log: typeof logger, state: ConsentState, source: string) { - const result = safeParse(gcmConsentStateStrict, state) - if (result.success) + if (state === null || typeof state !== 'object') { + log.warn(`${source}: consent state must be an object, received ${JSON.stringify(state)}`) return - for (const issue of result.issues) - log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p => p.key).join('.') || ''})`) + } + for (const key in state) { + const issue = describeConsentIssue(key, (state as Record)[key]) + if (issue) + log.warn(`${source}: ${issue}`) + } } export function attachGcmConsent( diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 70537788a..46704eb15 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -1,4 +1,5 @@ import type { LuxGlobal, UserConfig } from '@speedcurve/lux' +import type { InferInput } from 'valibot' import type { RouteLocationNormalized } from 'vue-router' import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' import { useHead, useNuxtApp, useRouter } from 'nuxt/app' @@ -25,10 +26,28 @@ export type SpeedCurveInput = Omit label?: string | ((to: RouteLocationNormalized) => string | false) | false } -// Derived from the schema: all schema keys except the composable-only ones. -const LUX_USER_CONFIG_KEYS = Object.keys(SpeedCurveOptions.entries).filter( - k => k !== 'id' && k !== 'autoTrackSpaNavigations' && k !== 'spaMode', -) as (keyof UserConfig)[] +/** Schema keys consumed by `useScriptSpeedCurve` itself, never forwarded to LUX. */ +type ComposableOnlyKey = 'id' | 'spaMode' | 'autoTrackSpaNavigations' +type ForwardedKey = Exclude, ComposableOnlyKey> + +// Listed rather than derived from `SpeedCurveOptions.entries`, so a production build +// drops the schema and valibot with it. The type guards below fail to compile if this +// list and the schema drift apart. +const LUX_USER_CONFIG_KEYS = [ + 'label', + 'samplerate', + 'sendBeaconOnPageHidden', + 'trackErrors', + 'maxErrors', + 'minMeasureTime', + 'maxMeasureTime', + 'newBeaconOnPageShow', + 'trackHiddenPages', + 'cookieDomain', +] as const satisfies readonly (ForwardedKey & keyof UserConfig)[] + +type AssertNever = T +type _AllForwardedKeysListed = AssertNever> let luxWired = false let teardownAutoTracker = () => {} diff --git a/test/unit/gcm-consent.test.ts b/test/unit/gcm-consent.test.ts new file mode 100644 index 000000000..e4cc73840 --- /dev/null +++ b/test/unit/gcm-consent.test.ts @@ -0,0 +1,53 @@ +import type { ConsentState } from '../../packages/script/src/runtime/types' +import { describe, expect, it, vi } from 'vitest' +import { validateConsentState } from '../../packages/script/src/runtime/registry/_gcm-consent' + +function collectWarnings(state: unknown): string[] { + const warn = vi.fn() + validateConsentState({ warn } as any, state as ConsentState, 'consent.update()') + return warn.mock.calls.map(([message]) => message as string) +} + +describe('validateConsentState', () => { + it('accepts a valid partial GCMv2 state', () => { + expect(collectWarnings({ + ad_storage: 'granted', + analytics_storage: 'denied', + wait_for_update: 500, + region: ['AU', 'NZ'], + })).toEqual([]) + }) + + it('warns once per unknown key', () => { + const warnings = collectWarnings({ analytics_storages: 'granted', ad_storag: 'denied' }) + expect(warnings).toHaveLength(2) + expect(warnings[0]).toContain('analytics_storages') + expect(warnings[1]).toContain('ad_storag') + }) + + it('warns on a consent value outside granted/denied', () => { + const warnings = collectWarnings({ ad_storage: 'allow' }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('ad_storage') + expect(warnings[0]).toContain('"allow"') + }) + + it('warns when wait_for_update is not a number', () => { + expect(collectWarnings({ wait_for_update: '500' })[0]).toContain('wait_for_update') + expect(collectWarnings({ wait_for_update: Number.NaN })[0]).toContain('wait_for_update') + }) + + it('warns when region is not an array of strings', () => { + expect(collectWarnings({ region: 'AU' })[0]).toContain('region') + expect(collectWarnings({ region: ['AU', 2] })[0]).toContain('region') + }) + + it('warns when the state is not an object', () => { + expect(collectWarnings(null)[0]).toContain('must be an object') + expect(collectWarnings('granted')[0]).toContain('must be an object') + }) + + it('reports the calling source in each warning', () => { + expect(collectWarnings({ nope: 'granted' })[0]).toContain('consent.update()') + }) +})