Summary
Bumping @juspay/blend-design-system from 0.0.38-beta.0 → 0.0.38-beta.1 — with the bindgen version held constant — silently widens hundreds of previously-structured token record fields to bare string. The blend token source for the affected components is byte-identical between the two versions, so this is a bindgen regression, not an upstream change.
The trigger is blend beta.1's one new type: ComponentTokenOverrides = DeepPartial<ComponentTokenType> (a recursive conditional/mapped wrapper over the whole component-token tree, added for ThemeProvider's override prop). Its presence deepens the global type graph and pushes nested token configs past MAX_DEPTH (extract.mjs), and the healGhostRecords recovery pass does not rescue them because their leaf fields are complex generics (FontSize<…>, FontWeight, LineHeight<…>) whose re-resolution registers new entries — which the heal explicitly rejects.
Impact — record → string widenings (same bindgen, only blend version differs)
blend version (bindgen = current main) |
record-shaped ⚪ loose — was \{…` widenings |
0.0.38-beta.0 |
40 |
0.0.38-beta.1 |
350 |
+310 fields silently downgraded from a structured record to string, across 45 *Types.res modules. Consumers who previously got a typed token config now get an opaque string.
Proof it is NOT a blend source change
Of blend's 57 *.tokens.types.ts source files, exactly one (ButtonV2) changed between beta.0 and beta.1 (diff -q). Every affected component — AccordionV2, Card, DataTable, MultiSelect, SingleSelect, StatCard, StatCardV2, Upload, Charts, Directory, … — has identical token source, yet all regressed.
Clean before/after, same bindgen (main), only the blend version changed — src/AccordionV2Types.res:
// blend 0.0.38-beta.0
type accordionV2AccordionV2TokensTypeTriggerTextConfig = {
gap: string,
title: accordionV2AccordionV2TokensTypeTriggerTextTitleConfig, // structured record ✅
subtext: accordionV2AccordionV2TokensTypeTriggerTextSubtextConfig,
}
// blend 0.0.38-beta.1
type accordionV2AccordionV2TokensTypeTriggerTextConfig = {
gap: string,
title: string, // ⚪ loose — was `{ fontSize: FontSize<number | (string & {})>; fontWeight: FontWeight; lineHeight: LineHeight<number | (string ` ❌
subtext: string, // ⚪ loose — was `{ fontSize: FontSize<number | (string & {})>; fontWeight: FontWeight; lineHeight: LineHeight<number | (string ` ❌
}
The accordionV2AccordionV2TokensTypeTriggerTextTitleConfig / …SubtextConfig record types are eliminated entirely in beta.1.
Most-affected modules (beta.1, record→string count)
ContextSharedTypes 88 · SelectorV2 23 · DateRangePicker 14 · Checkbox 14 · StatCard 13
Switch 11 · Radio 9 · TabsV2 8 · StepperV2 8 · Sidebar 8 · DataTable 8 · AvatarV2 8
Tabs 7 · Stepper 7 · SingleSelect 7 · PopoverV2 7 · MultiSelect 7 · Card 7 · Button 7 …
(45 modules total)
Root cause (suspected)
MAX_DEPTH = 6 in extract.mjs. A shared token config record whose first encounter is deep (now reachable via the DeepPartial<ComponentTokenType> expansion, which adds conditional/mapped indirection levels) is built entirely past the depth budget → all fields opaque → an all-string ghost, then frozen. healGhostRecords (#33) is designed to recover exactly this, but it accepts a re-resolve only when it introduces zero new registry entries. Blend's token leaves are complex generics (FontSize<number | (string & {})>, FontWeight, LineHeight<…>), so re-resolving them registers new entries → the heal is rejected → the widened ghost is kept.
So the two safeguards interact badly: the depth bound truncates because of the new deep path, and the ghost-heal refuses to recover because the leaves aren't already-registered.
Repro
npx @juspay/rescript-bindgen --pkg @juspay/blend-design-system@0.0.38-beta.0 --out /tmp/b0 --webapi --report
npx @juspay/rescript-bindgen --pkg @juspay/blend-design-system@0.0.38-beta.1 --out /tmp/b1 --webapi --report
diff <(grep -c '⚪ loose — was `{' /tmp/b0/*.res) ... # 40 vs 350
# or just open /tmp/b0/AccordionV2Types.res vs /tmp/b1/AccordionV2Types.res
blend 0.0.38-beta.1 is already pinned in the benchmark, so this is reproducible in-repo.
Fix direction
The heal should be allowed to recover a deep-first ghost even when re-resolving registers new entries, provided the re-resolve is still bounded (introduces no unbounded library-graph growth) — i.e. distinguish "new entries that are themselves leaves/small configs" (blend token tree, bounded) from "new entries that pull an unbounded graph" (Highcharts). A depth-0 re-resolve gated on a per-heal registry-growth cap (rather than strict zero) would recover these token records while keeping the Highcharts bound. Alternatively, seed the token-config records from a shallow traversal pass before the deep ComponentTokenOverrides path is visited, so their first encounter is shallow.
Summary
Bumping
@juspay/blend-design-systemfrom0.0.38-beta.0→0.0.38-beta.1— with the bindgen version held constant — silently widens hundreds of previously-structured token record fields to barestring. The blend token source for the affected components is byte-identical between the two versions, so this is a bindgen regression, not an upstream change.The trigger is blend beta.1's one new type:
ComponentTokenOverrides = DeepPartial<ComponentTokenType>(a recursive conditional/mapped wrapper over the whole component-token tree, added forThemeProvider's override prop). Its presence deepens the global type graph and pushes nested token configs pastMAX_DEPTH(extract.mjs), and thehealGhostRecordsrecovery pass does not rescue them because their leaf fields are complex generics (FontSize<…>,FontWeight,LineHeight<…>) whose re-resolution registers new entries — which the heal explicitly rejects.Impact — record → string widenings (same bindgen, only blend version differs)
main)⚪ loose — was \{…` widenings0.0.38-beta.00.0.38-beta.1+310 fields silently downgraded from a structured record to
string, across 45*Types.resmodules. Consumers who previously got a typed token config now get an opaquestring.Proof it is NOT a blend source change
Of blend's 57
*.tokens.types.tssource files, exactly one (ButtonV2) changed between beta.0 and beta.1 (diff -q). Every affected component — AccordionV2, Card, DataTable, MultiSelect, SingleSelect, StatCard, StatCardV2, Upload, Charts, Directory, … — has identical token source, yet all regressed.Clean before/after, same bindgen (
main), only the blend version changed —src/AccordionV2Types.res:The
accordionV2AccordionV2TokensTypeTriggerTextTitleConfig/…SubtextConfigrecord types are eliminated entirely in beta.1.Most-affected modules (beta.1, record→string count)
(45 modules total)
Root cause (suspected)
MAX_DEPTH = 6inextract.mjs. A shared token config record whose first encounter is deep (now reachable via theDeepPartial<ComponentTokenType>expansion, which adds conditional/mapped indirection levels) is built entirely past the depth budget → all fields opaque → an all-stringghost, then frozen.healGhostRecords(#33) is designed to recover exactly this, but it accepts a re-resolve only when it introduces zero new registry entries. Blend's token leaves are complex generics (FontSize<number | (string & {})>,FontWeight,LineHeight<…>), so re-resolving them registers new entries → the heal is rejected → the widened ghost is kept.So the two safeguards interact badly: the depth bound truncates because of the new deep path, and the ghost-heal refuses to recover because the leaves aren't already-registered.
Repro
blend
0.0.38-beta.1is already pinned in the benchmark, so this is reproducible in-repo.Fix direction
The heal should be allowed to recover a deep-first ghost even when re-resolving registers new entries, provided the re-resolve is still bounded (introduces no unbounded library-graph growth) — i.e. distinguish "new entries that are themselves leaves/small configs" (blend token tree, bounded) from "new entries that pull an unbounded graph" (Highcharts). A depth-0 re-resolve gated on a per-heal registry-growth cap (rather than strict zero) would recover these token records while keeping the Highcharts bound. Alternatively, seed the token-config records from a shallow traversal pass before the deep
ComponentTokenOverridespath is visited, so their first encounter is shallow.