You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
When a union can't be an `@unboxed` variant — **multiple object shapes**, or **object | array<object>**
314
314
(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):
319
319
| Union member | Module arm |
320
320
|---|---|
321
321
| 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) |
322
323
|`null` / `void` in a **callback return**|`let none: t = fromUnit()` — `unit`'s runtime value IS `undefined`|
323
324
| any member carrying an inner imperfection | the WHOLE module is rejected (deep `irHasImperfection` check) — no unflagged `=> string` fake can hide inside a view |
324
325
| 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) |
| 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.
0 commit comments