Skip to content

new globalThis.X(...) ignores the qualifier and builds a same-named imported class instead of the intrinsic #10359

Description

@proggeramlug

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 — correct

Reproducer

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

  1. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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 Event declarations 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 local export class Error plus instanceof globalThis.Error at line 254):

    export class Error {
      readonly _tag = "Snapshot.Error"
      constructor(public message?: string) {}
    }
    const realError = (() => { try { null.x } catch (e) { return e } })()
    const localError = new Error("local")
    # cell bun perry
    1 realError instanceof globalThis.Error true true ok
    2 localError instanceof globalThis.Error false false ok
    3 localError instanceof Error (local) true true ok
    4 realError instanceof Error (local) false false ok
    6 msg(localError) — the snapshot.ts:254 expression [object Object] [object Object] ok
    7 new globalThis.Error('x').message x x ok
    8 new globalThis.Error('x')._tag undefined undefined ok
    9 globalThis.Error === Error false false ok

    All nine agree. So snapshot.ts and ripgrep.ts are not affected, and neither is cross-spawn-spawner.ts (its globalThis.Error use has no Error import 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.X appears correct in every case I measured; only the construct form (new globalThis.X(...)) is wrong.
    • With no shadowing binding at all, every globalThis.-qualified form is correct.

    So the precise trigger is: new globalThis.X(...) in a module that has an imported binding named X.

    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.X exists specifically to escape a shadow, but it should be prioritized accordingly.

  2. proggeramlug commented on Sep 17, 2026

    @proggeramlug
    ContributorAuthor

    Fixed on main by #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 #10359 keyword 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions