Skip to content

Commit 7dd5880

Browse files
authored
perf: keep valibot out of production client bundles (#869)
1 parent 151a94b commit 7dd5880

3 files changed

Lines changed: 124 additions & 14 deletions

File tree

‎packages/script/src/runtime/registry/_gcm-consent.ts‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
11
importtype{ConsentState,GcmConsentApi,UseScriptContext}from'../types'
2-
import{safeParse,strictObject}from'valibot'
32
import{logger}from'../logger'
4-
import{gcmConsentState}from'./schemas'
53

64
exporttype{GcmConsentApi}
75

8-
// Strict variant rebuilt from the lenient schema's entries — same shape, but
9-
// unknown keys produce issues so we can warn on typos without breaking the
10-
// lenient `defaultConsent` schema parse used at build time.
11-
constgcmConsentStateStrict=strictObject(gcmConsentState.entries)
6+
// GCMv2 consent categories. Every entry takes `granted` or `denied`.
7+
constCONSENT_CATEGORIES=[
8+
'ad_storage',
9+
'ad_user_data',
10+
'ad_personalization',
11+
'analytics_storage',
12+
'functionality_storage',
13+
'personalization_storage',
14+
'security_storage',
15+
]asconstsatisfiesreadonly(keyofConsentState)[]
16+
17+
constCONSENT_CATEGORY_KEYS: ReadonlySet<string>=newSet(CONSENT_CATEGORIES)
18+
19+
// Fails to compile if `ConsentState` grows a key this validator does not know about.
20+
typeAssertNever<Textendsnever>=T
21+
type_ConsentKeysChecked=AssertNever<Exclude<keyofConsentState,typeofCONSENT_CATEGORIES[number]|'wait_for_update'|'region'>>
22+
23+
/**
24+
* Describe what is wrong with one consent entry, or return `null` when it is valid.
25+
* The canonical schema stays lenient so unknown keys pass a schema parse; here we
26+
* reject them, because an unknown key is almost always a typo the user wants to see.
27+
*/
28+
functiondescribeConsentIssue(key: string,value: unknown): string|null{
29+
if(CONSENT_CATEGORY_KEYS.has(key)){
30+
returnvalue==='granted'||value==='denied'
31+
? null
32+
: `"${key}" must be "granted" or "denied", received ${JSON.stringify(value)}`
33+
}
34+
if(key==='wait_for_update'){
35+
returntypeofvalue==='number'&&Number.isFinite(value)
36+
? null
37+
: `"wait_for_update" must be a number, received ${JSON.stringify(value)}`
38+
}
39+
if(key==='region'){
40+
returnArray.isArray(value)&&value.every(entry=>typeofentry==='string')
41+
? null
42+
: `"region" must be an array of strings, received ${JSON.stringify(value)}`
43+
}
44+
return`unknown key "${key}"`
45+
}
1246

1347
/**
1448
* GCMv2 consent contract returned by registry scripts (GA, GTM, future Google Ads, …).
@@ -21,11 +55,15 @@ export interface GcmConsentContract {
2155

2256
/** Validate a partial GCMv2 consent state. Logs each issue via the registry-scoped logger. */
2357
exportfunctionvalidateConsentState(log: typeoflogger,state: ConsentState,source: string){
24-
constresult=safeParse(gcmConsentStateStrict,state)
25-
if(result.success)
58+
if(state===null||typeofstate!=='object'){
59+
log.warn(`${source}: consent state must be an object, received ${JSON.stringify(state)}`)
2660
return
27-
for(constissueofresult.issues)
28-
log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p=>p.key).join('.')||'<root>'})`)
61+
}
62+
for(constkeyinstate){
63+
constissue=describeConsentIssue(key,(stateasRecord<string,unknown>)[key])
64+
if(issue)
65+
log.warn(`${source}: ${issue}`)
66+
}
2967
}
3068

3169
exportfunctionattachGcmConsent(

‎packages/script/src/runtime/registry/speedcurve.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
importtype{LuxGlobal,UserConfig}from'@speedcurve/lux'
2+
importtype{InferInput}from'valibot'
23
importtype{RouteLocationNormalized}from'vue-router'
34
importtype{RegistryScriptInput,UseScriptContext}from'#nuxt-scripts/types'
45
import{useHead,useNuxtApp,useRouter}from'nuxt/app'
@@ -25,10 +26,28 @@ export type SpeedCurveInput = Omit<RegistryScriptInput<typeof SpeedCurveOptions>
2526
label?: string|((to: RouteLocationNormalized)=>string|false)|false
2627
}
2728

28-
// Derived from the schema: all schema keys except the composable-only ones.
29-
constLUX_USER_CONFIG_KEYS=Object.keys(SpeedCurveOptions.entries).filter(
30-
k=>k!=='id'&&k!=='autoTrackSpaNavigations'&&k!=='spaMode',
31-
)as(keyofUserConfig)[]
29+
/** Schema keys consumed by `useScriptSpeedCurve` itself, never forwarded to LUX. */
30+
typeComposableOnlyKey='id'|'spaMode'|'autoTrackSpaNavigations'
31+
typeForwardedKey=Exclude<keyofInferInput<typeofSpeedCurveOptions>,ComposableOnlyKey>
32+
33+
// Listed rather than derived from `SpeedCurveOptions.entries`, so a production build
34+
// drops the schema and valibot with it. The type guards below fail to compile if this
35+
// list and the schema drift apart.
36+
constLUX_USER_CONFIG_KEYS=[
37+
'label',
38+
'samplerate',
39+
'sendBeaconOnPageHidden',
40+
'trackErrors',
41+
'maxErrors',
42+
'minMeasureTime',
43+
'maxMeasureTime',
44+
'newBeaconOnPageShow',
45+
'trackHiddenPages',
46+
'cookieDomain',
47+
]asconstsatisfiesreadonly(ForwardedKey&keyofUserConfig)[]
48+
49+
typeAssertNever<Textendsnever>=T
50+
type_AllForwardedKeysListed=AssertNever<Exclude<ForwardedKey,typeofLUX_USER_CONFIG_KEYS[number]>>
3251

3352
letluxWired=false
3453
letteardownAutoTracker=()=>{}

‎test/unit/gcm-consent.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
importtype{ConsentState}from'../../packages/script/src/runtime/types'
2+
import{describe,expect,it,vi}from'vitest'
3+
import{validateConsentState}from'../../packages/script/src/runtime/registry/_gcm-consent'
4+
5+
functioncollectWarnings(state: unknown): string[]{
6+
constwarn=vi.fn()
7+
validateConsentState({ warn }asany,stateasConsentState,'consent.update()')
8+
returnwarn.mock.calls.map(([message])=>messageasstring)
9+
}
10+
11+
describe('validateConsentState',()=>{
12+
it('accepts a valid partial GCMv2 state',()=>{
13+
expect(collectWarnings({
14+
ad_storage: 'granted',
15+
analytics_storage: 'denied',
16+
wait_for_update: 500,
17+
region: ['AU','NZ'],
18+
})).toEqual([])
19+
})
20+
21+
it('warns once per unknown key',()=>{
22+
constwarnings=collectWarnings({analytics_storages: 'granted',ad_storag: 'denied'})
23+
expect(warnings).toHaveLength(2)
24+
expect(warnings[0]).toContain('analytics_storages')
25+
expect(warnings[1]).toContain('ad_storag')
26+
})
27+
28+
it('warns on a consent value outside granted/denied',()=>{
29+
constwarnings=collectWarnings({ad_storage: 'allow'})
30+
expect(warnings).toHaveLength(1)
31+
expect(warnings[0]).toContain('ad_storage')
32+
expect(warnings[0]).toContain('"allow"')
33+
})
34+
35+
it('warns when wait_for_update is not a number',()=>{
36+
expect(collectWarnings({wait_for_update: '500'})[0]).toContain('wait_for_update')
37+
expect(collectWarnings({wait_for_update: Number.NaN})[0]).toContain('wait_for_update')
38+
})
39+
40+
it('warns when region is not an array of strings',()=>{
41+
expect(collectWarnings({region: 'AU'})[0]).toContain('region')
42+
expect(collectWarnings({region: ['AU',2]})[0]).toContain('region')
43+
})
44+
45+
it('warns when the state is not an object',()=>{
46+
expect(collectWarnings(null)[0]).toContain('must be an object')
47+
expect(collectWarnings('granted')[0]).toContain('must be an object')
48+
})
49+
50+
it('reports the calling source in each warning',()=>{
51+
expect(collectWarnings({nope: 'granted'})[0]).toContain('consent.update()')
52+
})
53+
})

0 commit comments

Comments
 (0)