Repository navigation
Description
Summary
new globalThis.X(...) ignores the globalThis. qualifier and resolves X by bare name. When a module legitimately imports a user class that shadows a global, the qualified form builds the user class instead of the intrinsic.
The aliased form works, which is what makes this two lowerings rather than one broken one:
new globalThis.Event("ping") // perry: the USER class
const E = globalThis.Event; new E("ping") // perry: the intrinsic — correctReproducer
ev.ts:
export class Event { readonly tag = "user-Event" }main.ts:
// @ts-nocheck
import { Event } from "./ev.js" // a legitimate, explicit import that shadows the bare name
const e: any = new globalThis.Event("ping")
console.log("tag:", e.tag)
console.log("type:", e.type)
console.log("ctor-name:", e.constructor?.name)
console.log("is-user-class:", e instanceof Event)Measured (perry @ v0.5.1579 + #10356, vs bun 1.3.14)
| cell | bun | perry | |
|---|---|---|---|
e.tag |
undefined |
user-Event |
✗ |
e.type |
ping |
undefined |
✗ |
e.constructor?.name |
Event |
Event |
ok (both classes are named Event — this cell cannot discriminate) |
e instanceof Event |
false |
true |
✗ decisive |
The last cell is the proof: the value built through globalThis.Event is an instance of the imported class.
Wider matrix on the same fixture — only the direct qualified new is affected:
| cell | bun | perry |
|---|---|---|
new Event().tag (the local binding, legitimately the user class) |
user-Event |
user-Event ok |
typeof globalThis.Event |
function |
function ok |
globalThis.Event?.name |
Event |
Event ok |
new globalThis.Event("ping").type |
ping |
undefined ✗ |
const E = globalThis.Event; new E("ping").type |
ping |
ping ok |
new globalThis.CustomEvent("x",{detail:7}).detail |
7 |
7 ok |
With no shadowing user class in scope, every globalThis.-qualified form is correct (Event, CustomEvent, URL, Headers all pass). The defect only appears once a same-named binding exists in the module.
Why it matters
globalThis.X is the idiom people reach for precisely to escape a local shadow — it is the documented workaround for this situation, so silently resolving it to the shadow defeats its only purpose.
OpenCode's graph has many such shadows: packages/sdk/js/src/v2/gen/sdk.gen.ts exports Event, File and Request; packages/core exports Error twice; Effect exports Request, WebSocket, FormData and Storage.
Relationship to other issues
Distinct from #10356. There the class was never imported and should not have been in scope at all. Here the import is legitimate and the bare name should be the user class — but the globalThis.-qualified read must still reach the intrinsic.
Same family as #10303 (global.x reads collapsing to the intrinsic surface while the write landed): a qualified access and its unqualified sibling take different lowerings, and only one honors the qualifier. The asymmetry between the direct and aliased forms above is the signature.
Activity
proggeramlug commentedon Sep 16, 2026
Correction: the trigger is an IMPORTED shadow, not a local one
My "Why it matters" section implied OpenCode's many
export class Error/export class Eventdeclarations put this on a live path. Having measured it, that is not right and I want the narrowing on the record before anyone chases it.A local shadowing declaration in the same module does not trigger the bug. Fixture matching OpenCode's
packages/core/src/snapshot.ts(a localexport class Errorplusinstanceof globalThis.Errorat line 254):realError instanceof globalThis.ErrortruetruelocalError instanceof globalThis.ErrorfalsefalselocalError instanceof Error(local)truetruerealError instanceof Error(local)falsefalsemsg(localError)— the snapshot.ts:254 expression[object Object][object Object]new globalThis.Error('x').messagexxnew globalThis.Error('x')._tagundefinedundefinedglobalThis.Error === ErrorfalsefalseAll nine agree. So
snapshot.tsandripgrep.tsare not affected, and neither iscross-spawn-spawner.ts(itsglobalThis.Erroruse has noErrorimport in scope).The reproducer in the issue body stands exactly as written — it uses
import { Event } from "./ev.js", an imported binding, and that is the necessary condition. Two independent axes also do not trigger it on their own:instanceof globalThis.Xappears correct in every case I measured; only the construct form (new globalThis.X(...)) is wrong.globalThis.-qualified form is correct.So the precise trigger is:
new globalThis.X(...)in a module that has an imported binding namedX.I have not found a live instance of that conjunction in OpenCode, so this is a correctness bug without a known OpenCode repro today — it is filed on the strength of the reproducer, not on an observed failure. Worth fixing because
globalThis.Xexists specifically to escape a shadow, but it should be prioritized accordingly.fix(codegen): build the intrinsic, not a runtime read, for a shadowed…
fix(hir,codegen): construct the global in `new globalThis.X()` when a…
fix(codegen): build the intrinsic, not a runtime read, for a shadowed…
proggeramlug commentedon Sep 17, 2026
Fixed on
mainby #10375, which landed via merge train 207 (v0.5.1585,ff19bd536a).Closing manually: a merge train cherry-picks its source PRs and lands them as its own PR, so #10375 was closed rather than merged and its
Fixes #10359keyword never evaluated. The train's merge receipt records the landed tree as identical to the validated train, with per-commit patch-id and authorship proofs for every source commit.