Skip to content

Commit c77ae3b

Browse files
authored
Collapse large string-literal runs in opaque modules to one polyvar arm (#53) (#54)
## What Closes #53. A non-discriminable union whose string-literal members form a **large set** now collapses them into one polyvar constructor instead of one `external`+`let` pair per literal. This is the shape React's `ElementType` / `keyof JSX.IntrinsicElements` expands to (~170 tag names) when it lands in a union with ≥2 object arms that force the opaque-module path — the live case is **react-tooltip's `wrapper`** prop (`react-tooltip.d.ts:19`: `type WrapperType = ElementType | 'div' | 'span'`). ## Before → after (real `react-tooltip` output) **Before** — `DistTypes.res` = **398 lines**, 174 literals × 2 lines each: ```rescript module WrapperType = { type t external fromSymbol: [#"symbol"] => t = "%identity" let symbol: t = fromSymbol(#"symbol") external fromObject: [#"object"] => t = "%identity" let object: t = fromObject(#"object") … ×174 … external fromComponentClass: React.component<'a> => t = "%identity" external fromFunctionComponent: React.component<'a> => t = "%identity" } ``` **After** — `DistTypes.res` = **43 lines**: ```rescript module WrapperType = { type t external fromTag: [#"symbol" | #"object" | #"a" | #"div" | #"span" | #"svg" | #"feGaussianBlur" | … ] => t = "%identity" external fromComponentClass: React.component<'a> => t = "%identity" external fromFunctionComponent: React.component<'a> => t = "%identity" } ``` Consumer: `wrapper={WrapperType.fromTag(#div)}` — compile-verified, including `#feGaussianBlur`. ## Why it's exact (not a downgrade) The polyvar `[#"div" | …]` admits **exactly** those 178 strings — a typo like `#dvi` is rejected, same as the 174 individual constants. Each value passes through unchanged (`%identity`), so it's identical at runtime. We trade ~340 lines for one, with the same type guarantee. ## Scope & safety - **Only `react-tooltip` was affected** (−357 baseline lines, one file); base-ui / blend / day-picker have no union with this shape. - A **small** literal set (< 4) still emits readable named constants — `Boundary.clippingAncestors`, `Mode.true_` etc. are unchanged. - A `tagSet` arm carries **no collision risk** (its arms are raw-string polyvars, so `'trap-focus'` vs `'trapFocus'` stay distinct) and still trips the #39 **receive-position construct-only guard**. - New fixture `literal-run-collapse` (6 tags + 2 object arms → `fromTag` + two `from*`); 40 goldens match + compile; bench 8/8 PASS. ## Open design choice (resolved) Dropped the per-tag convenience `let div` / `let span` constants (the bulk of the bloat) in favor of `fromTag(#div)`. If you'd rather keep them alongside `fromTag`, easy to add — but the polyvar form is what makes it ~85% smaller.
1 parent f84614c commit c77ae3b

10 files changed

Lines changed: 124 additions & 365 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ This project adheres to [Semantic Versioning](https://semver.org/).
55

66
## [Unreleased]
77

8+
### Changed
9+
- **Large string-literal runs in opaque modules collapse to one polyvar arm** (#53):
10+
a non-discriminable union whose literal members are a big set (React's
11+
`ElementType`/`keyof JSX.IntrinsicElements` -> ~170 tag names, e.g.
12+
react-tooltip's `wrapper`) now emits `external fromTag: [#"a" | #"div" | …] => t`
13+
instead of one `external`+`let` pair per literal. Same exactness (the polyvar
14+
admits exactly that set, leak-free) — react-tooltip `DistTypes.res` 398 -> 43
15+
lines. A small literal set (< 4) keeps readable named constants.
16+
817
### Added
918
- **Self-returning chained methods** via non-exported first-party base classes
1019
(hono's `get/post/…` returning `HonoBase<…>`) now map to the chainable `t`

benchmark/baselines/react-tooltip/bindings/DistTypes.res

Lines changed: 1 addition & 356 deletions
Large diffs are not rendered by default.

benchmark/baselines/react-tooltip/bindings/Tooltip.res

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ external make: (
1010
~id: string=?,
1111
~variant: DistTypes.variantType=?,
1212
~anchorSelect: string=?,
13-
~wrapper: DistTypes.WrapperType.t=?, // ⓘ was `WrapperType` — opaque; build with WrapperType.symbol / WrapperType.object / WrapperType.map / WrapperType.filter / WrapperType.a / WrapperType.abbr / WrapperType.address / WrapperType.area / WrapperType.article / WrapperType.aside / WrapperType.audio / WrapperType.b / WrapperType.base / WrapperType.bdi / WrapperType.bdo / WrapperType.big / WrapperType.blockquote / WrapperType.body / WrapperType.br / WrapperType.button / WrapperType.canvas / WrapperType.caption / WrapperType.center / WrapperType.cite / WrapperType.code / WrapperType.col / WrapperType.colgroup / WrapperType.data / WrapperType.datalist / WrapperType.dd / WrapperType.del / WrapperType.details / WrapperType.dfn / WrapperType.dialog / WrapperType.div / WrapperType.dl / WrapperType.dt / WrapperType.em / WrapperType.embed / WrapperType.fieldset / WrapperType.figcaption / WrapperType.figure / WrapperType.footer / WrapperType.form / WrapperType.h1 / WrapperType.h2 / WrapperType.h3 / WrapperType.h4 / WrapperType.h5 / WrapperType.h6 / WrapperType.head / WrapperType.header / WrapperType.hgroup / WrapperType.hr / WrapperType.html / WrapperType.i / WrapperType.iframe / WrapperType.img / WrapperType.input / WrapperType.ins / WrapperType.kbd / WrapperType.keygen / WrapperType.label / WrapperType.legend / WrapperType.li / WrapperType.link / WrapperType.main / WrapperType.mark / WrapperType.menu / WrapperType.menuitem / WrapperType.meta / WrapperType.meter / WrapperType.nav / WrapperType.noindex / WrapperType.noscript / WrapperType.ol / WrapperType.optgroup / WrapperType.option / WrapperType.output / WrapperType.p / WrapperType.param / WrapperType.picture / WrapperType.pre / WrapperType.progress / WrapperType.q / WrapperType.rp / WrapperType.rt / WrapperType.ruby / WrapperType.s / WrapperType.samp / WrapperType.search / WrapperType.slot / WrapperType.script / WrapperType.section / WrapperType.select / WrapperType.small / WrapperType.source / WrapperType.span / WrapperType.strong / WrapperType.style / WrapperType.sub / WrapperType.summary / WrapperType.sup / WrapperType.table / WrapperType.template / WrapperType.tbody / WrapperType.td / WrapperType.textarea / WrapperType.tfoot / WrapperType.th / WrapperType.thead / WrapperType.time / WrapperType.title / WrapperType.tr / WrapperType.track / WrapperType.u / WrapperType.ul / WrapperType.var / WrapperType.video / WrapperType.wbr / WrapperType.webview / WrapperType.svg / WrapperType.animate / WrapperType.animateMotion / WrapperType.animateTransform / WrapperType.circle / WrapperType.clipPath / WrapperType.defs / WrapperType.desc / WrapperType.ellipse / WrapperType.feBlend / WrapperType.feColorMatrix / WrapperType.feComponentTransfer / WrapperType.feComposite / WrapperType.feConvolveMatrix / WrapperType.feDiffuseLighting / WrapperType.feDisplacementMap / WrapperType.feDistantLight / WrapperType.feDropShadow / WrapperType.feFlood / WrapperType.feFuncA / WrapperType.feFuncB / WrapperType.feFuncG / WrapperType.feFuncR / WrapperType.feGaussianBlur / WrapperType.feImage / WrapperType.feMerge / WrapperType.feMergeNode / WrapperType.feMorphology / WrapperType.feOffset / WrapperType.fePointLight / WrapperType.feSpecularLighting / WrapperType.feSpotLight / WrapperType.feTile / WrapperType.feTurbulence / WrapperType.foreignObject / WrapperType.g / WrapperType.image / WrapperType.line / WrapperType.linearGradient / WrapperType.marker / WrapperType.mask / WrapperType.metadata / WrapperType.mpath / WrapperType.path / WrapperType.pattern / WrapperType.polygon / WrapperType.polyline / WrapperType.radialGradient / WrapperType.rect / WrapperType.set / WrapperType.stop / WrapperType.switch_ / WrapperType.text / WrapperType.textPath / WrapperType.tspan / WrapperType.use / WrapperType.view / WrapperType.fromComponentClass / WrapperType.fromFunctionComponent
13+
~wrapper: DistTypes.WrapperType.t=?, // ⓘ was `WrapperType` — opaque; build with WrapperType.fromTag / WrapperType.fromComponentClass / WrapperType.fromFunctionComponent
1414
~children: React.element=?,
1515
~openOnClick: bool=?,
1616
~positionStrategy: DistTypes.positionStrategy=?,

docs/TYPE_MAPPING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ becomes `JSON.t`, `keyof T` becomes `string`, and nested records carry the type
308308
---
309309

310310
## Opaque-module unions
311-
Fixtures: [`opaque-modules`](../test/golden/cases/opaque-modules), [`webapi`](../test/golden/cases/webapi), [`overload-intersection`](../test/golden/cases/overload-intersection), [`vendor-views`](../test/golden/cases/vendor-views)
311+
Fixtures: [`opaque-modules`](../test/golden/cases/opaque-modules), [`webapi`](../test/golden/cases/webapi), [`overload-intersection`](../test/golden/cases/overload-intersection), [`vendor-views`](../test/golden/cases/vendor-views), [`literal-run-collapse`](../test/golden/cases/literal-run-collapse)
312312

313313
When a union can't be an `@unboxed` variant — **multiple object shapes**, or **object | array<object>**
314314
(abstract members that `typeof`/`Array.isArray` can't split into a recognized variant shape) — it
@@ -319,6 +319,7 @@ Three member forms beyond plain `from*` constructors (#39):
319319
| Union member | Module arm |
320320
|---|---|
321321
| a string LITERAL (`'clipping-ancestors' \| Element \| Rect`) | a ready-made constant via a single-value polyvar cast — `external fromClippingAncestors: [#"clipping-ancestors"] => t` + `let clippingAncestors: t = …`. The polyvar admits exactly that one value (it IS the string at runtime), so no open string cast leaks in |
322+
| a LARGE run of string literals (≥ 4 — React's `ElementType`/`keyof JSX.IntrinsicElements` expands to ~170 tag names) | ONE polyvar constructor `external fromTag: [#"a" \| #"div" \| …] => t` instead of ~2N constant lines. Same exactness (admits exactly that set, leak-free), ~85% smaller. A small set (< 4) keeps individual named constants. (#53) |
322323
| `null` / `void` in a **callback return** | `let none: t = fromUnit()``unit`'s runtime value IS `undefined` |
323324
| any member carrying an inner imperfection | the WHOLE module is rejected (deep `irHasImperfection` check) — no unflagged `=> string` fake can hide inside a view |
324325
| two arms that produce the **same constructor ident** (`'trap-focus'` vs `'trapFocus'`, or two anon functions) | the WHOLE module is rejected → prop stays flagged (all-cases-or-flag: never silently drop a variant) |

src/emit.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,17 @@ function renderOpaque(t, lines, cfg) {
647647
lines.push(` type t`)
648648
const seen = new Set()
649649
for (const m of t.members) {
650+
// A collapsed LITERAL RUN (#53) -> ONE polyvar constructor admitting exactly that
651+
// set: `external fromTag: [#"a" | #"div" | …] => t = "%identity"`. Each tag value
652+
// passes through unchanged (it IS the string), so this is exact and leak-free —
653+
// the same guarantee as N individual constants, ~1 line instead of ~2N.
654+
if (m.tagSet) {
655+
if (seen.has('fromTag')) continue
656+
seen.add('fromTag')
657+
const poly = m.tagSet.map((v) => `#"${v}"`).join(' | ')
658+
lines.push(` external fromTag: [${poly}] => t = "%identity"`)
659+
continue
660+
}
650661
// A string-LITERAL arm -> a ready-made constant: the polyvar `#"x"` admits exactly
651662
// that one value and compiles to the bare string, so nothing else can be cast in. (#39)
652663
if (m.literal !== undefined) {

src/extract.mjs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,10 @@ const REF_NAMES = /^(Ref|RefObject|MutableRefObject|LegacyRef)$/
11691169
* registered entry imperfection-free, and a bounded entry count (no graph pull).
11701170
* Anything less rolls back fully and keeps the honest flag. */
11711171
const VENDOR_TRIAL_ENTRY_CAP = 8
1172+
// At/above this many string-literal arms in an opaque module, collapse them into ONE
1173+
// `fromTag: [#"…" | …]` polyvar constructor instead of one named constant each. Below it,
1174+
// keep readable named constants (`Boundary.clippingAncestors`). (#53)
1175+
const LITERAL_COLLAPSE_THRESHOLD = 4
11721176
function trialVendorRecord(type, ctx, propName, named) {
11731177
const shared = ctx.shared
11741178
if (!shared) return null
@@ -1760,13 +1764,17 @@ function opaqueUnion(ctx, type, memberTypes, propName, depth, opts = {}) {
17601764
const { checker } = ctx
17611765
const members = []
17621766
let sawBool = false
1767+
// Collect string-literal arms in one slot (keeps their original position). A SMALL set
1768+
// stays individual ready-made constants (`let clippingAncestors`); a LARGE run (React's
1769+
// `ElementType`/`keyof JSX.IntrinsicElements` expands to ~170 tag literals) collapses to
1770+
// ONE polyvar constructor `external fromTag: [#"a" | #"div" | …] => t` instead of
1771+
// ~340 lines — same exactness (the polyvar admits exactly that set), leak-free. (#53)
1772+
const literalRun = []
1773+
let litSlot = -1
17631774
for (const mt of memberTypes) {
1764-
// A string-LITERAL arm (`'clippingAncestors' | Element | …`, #39) becomes a
1765-
// ready-made constant: `external fromX: [#x] => t` + `let x: t = fromX(#x)` —
1766-
// the polyvar admits exactly that one value (it IS the string at runtime), so
1767-
// no open string cast leaks into the module.
17681775
if (mt.isStringLiteral && mt.isStringLiteral()) {
1769-
members.push({ literal: String(mt.value), name: String(mt.value) })
1776+
if (litSlot < 0) { litSlot = members.length; members.push(null) } // reserve position
1777+
literalRun.push(String(mt.value))
17701778
continue
17711779
}
17721780
// TS expands `boolean` to `true | false` — collapse to ONE fromBool arm. (#39)
@@ -1800,6 +1808,15 @@ function opaqueUnion(ctx, type, memberTypes, propName, depth, opts = {}) {
18001808
: refName(node) || undefined
18011809
members.push({ type: node, name })
18021810
}
1811+
// Fold the literal run into its reserved slot: collapse a large set to one `tagSet`
1812+
// polyvar arm; keep a small set as individual named constants. (#53)
1813+
if (litSlot >= 0) {
1814+
const uniq = [...new Set(literalRun)]
1815+
const folded = uniq.length >= LITERAL_COLLAPSE_THRESHOLD
1816+
? [{ tagSet: uniq, name: 'Tag' }]
1817+
: uniq.map((v) => ({ literal: v, name: v }))
1818+
members.splice(litSlot, 1, ...folded)
1819+
}
18031820
// `T | null/void` in a consumer-PRODUCED position (a callback's return, #39):
18041821
// `none` constant (unit cast — `()` compiles to `undefined`, exactly what `void`
18051822
// returns; the library treats null/undefined alike here).
@@ -1808,7 +1825,9 @@ function opaqueUnion(ctx, type, memberTypes, propName, depth, opts = {}) {
18081825
// Constructor-ident COLLISION check (#39 review): two literals that camel to the same
18091826
// ident (`'trap-focus'` vs `'trapFocus'`), or two same-named arms, would silently drop
18101827
// a TS variant (emit's `seen` dedup). All-cases-or-flag: reject the module instead.
1811-
const identOf = (m) => m.literal !== undefined ? lower(pascal(m.literal)) : m.none ? 'none' : ('from' + (m.name ? pascal(m.name) : (m.type && m.type.kind) || 'value'))
1828+
// (A `tagSet` arm carries no collision risk — its polyvar values are the raw strings,
1829+
// so `#"trap-focus"` and `#"trapFocus"` stay distinct; only ident-bearing arms count.)
1830+
const identOf = (m) => m.tagSet ? 'fromTag' : m.literal !== undefined ? lower(pascal(m.literal)) : m.none ? 'none' : ('from' + (m.name ? pascal(m.name) : (m.type && m.type.kind) || 'value'))
18121831
const idents = members.map(identOf)
18131832
if (new Set(idents).size !== idents.length) return null
18141833
const name = uniqueName(pascal(opts.nameHint || typeName(type) || propName), ctx.shared) // a MODULE name
@@ -1820,7 +1839,7 @@ function opaqueUnion(ctx, type, memberTypes, propName, depth, opts = {}) {
18201839
if (deps.size) { const d = ctx.shared.byKey.get([...deps][0]); if (d && d.home) home = d.home }
18211840
// Note telling the caller how to build this opaque value (the `from*` ctors),
18221841
// since the prop only shows `<Module>.t`. Mirrors the Dom-node note convention.
1823-
const ctorName = (m) => m.literal ? `${name}.${lower(pascal(m.literal))}` : m.none ? `${name}.none` : `${name}.from${pascal(m.name)}`
1842+
const ctorName = (m) => m.tagSet ? `${name}.fromTag` : m.literal ? `${name}.${lower(pascal(m.literal))}` : m.none ? `${name}.none` : `${name}.from${pascal(m.name)}`
18241843
const note = members.every((m) => m.name)
18251844
? `was \`${checker.typeToString(type).replace(/ \| (null|undefined)\b/g, '')}\` — opaque; build with ${members.map(ctorName).join(' / ')}`
18261845
: undefined
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
type jsxElement = {
2+
__brand: string,
3+
}
4+
type compClass = {
5+
render: unit => jsxElement,
6+
}
7+
type compFn = {
8+
tag: string,
9+
mount: unit => unit,
10+
}
11+
module WrapperLike = {
12+
type t
13+
external fromTag: [#"div" | #"span" | #"section" | #"article" | #"aside" | #"nav"] => t = "%identity"
14+
external fromCompClass: compClass => t = "%identity"
15+
external fromCompFn: compFn => t = "%identity"
16+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@module("demo") @react.component
2+
external make: (
3+
~wrapper: LiteralRunCollapseTypes.WrapperLike.t=?, // ⓘ was `WrapperLike` — opaque; build with WrapperLike.fromTag / WrapperLike.fromCompClass / WrapperLike.fromCompFn
4+
) => React.element = "Widget"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Binding report — `demo`
2+
3+
**1** components · ✅ **1** usable · 🔍 **0** need review · 🛑 **0** broken
4+
5+
**4** shared types deduplicated into **1** `*Types.res` modules (referenced qualified — no per-file redeclaration).
6+
7+
## 📦 Dependencies
8+
9+
| Kind | Package | Provides | Status |
10+
|------|---------|----------|--------|
11+
| required | `@rescript/react + stdlib` | JsxDOM, Dom, React, ReactEvent | ✓ present |
12+
| optional | `rescript-webapi` | File, FileList | ✗ not installed |
13+
14+
## ✅ Usable
15+
16+
These compile and every prop is bound type-safely — use them directly.
17+
_(n loose)_ = some props widened to `string`; they still work, just loosely typed.
18+
19+
- Widget
20+
21+
## ⚪ Loosely typed (widened to `string`)
22+
23+
These resolved to a real but complex type and were widened to `string` (they compile and work). Grouped by type so you can review each pattern once — confirm `string` is acceptable, or it may deserve a tighter mapping.
24+
25+
_(none)_
26+
27+
## 🔍 Needs review
28+
29+
A multi-type prop couldn't be auto-discriminated at runtime (e.g. two object shapes), so an `@unboxed` variant won't work and we **refuse to use `%identity`/unsafe casts**. The prop is emitted as a `string` placeholder with an inline `// ⚠️ REVIEW` comment — bind it by hand or fix the type upstream.
30+
31+
_(none)_
32+
33+
## 🛑 Broken — needs serious component change
34+
35+
These props resolved to `unknown`/`any` (usually a generic `T`). They're emitted as a placeholder so the file still compiles, but **the props will not work as typed** — they need a concrete type upstream, or generic-binding support.
36+
37+
_(none)_ 🎉
38+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// #53 — a non-discriminable union with a LARGE run of string literals (here a tag-name
2+
// set, the shape React's `ElementType`/`keyof JSX.IntrinsicElements` expands to) collapses
3+
// its literal arms into ONE `fromTag: [#"a" | …] => t` polyvar constructor instead of one
4+
// named constant per literal — same exactness, ~1 line instead of ~2N. The two object arms
5+
// (which can't be @unboxed-discriminated, forcing the opaque module) keep their own
6+
// `from*`. A SMALL literal set (< threshold) still emits named constants (see `vendor-views`
7+
// Boundary's `clippingAncestors`).
8+
type JsxElement = { __brand: 'element' }
9+
interface CompClass { render: () => JsxElement }
10+
interface CompFn { tag: string; mount: () => void }
11+
// 6 tag literals (>= threshold) + 2 OBJECT arms -> not @unboxed-able -> opaque module
12+
type WrapperLike = 'div' | 'span' | 'section' | 'article' | 'aside' | 'nav' | CompClass | CompFn
13+
14+
export declare const Widget: (props: {
15+
wrapper?: WrapperLike
16+
}) => JsxElement

0 commit comments

Comments
 (0)