Skip to content

Closure body () => new <ImportedClass>(...) inside a compilePackages module fails to link or silently collapses to the class id #588

Description

@proggeramlug

Repro

Minimal package under node_modules/repro-pkg2/:

// node_modules/repro-pkg2/dialect.js
export class PgDialect {
  constructor(opts) { this.casing = opts ? opts.casing : undefined; }
}

// node_modules/repro-pkg2/db.js
export class PgDatabase {
  constructor(dialect, session, schema) {
    this.dialect = dialect;
    this.session = session;
    this.schema = schema;
  }
}

// node_modules/repro-pkg2/session.js
export class PgRemoteSession {
  constructor(callback, dialect, schema, opts) {
    this.callback = callback;
    this.dialect = dialect;
  }
}

// node_modules/repro-pkg2/index.js
import { PgDatabase } from './db.js';
import { PgDialect } from './dialect.js';
import { PgRemoteSession } from './session.js';

class PgRemoteDatabase extends PgDatabase {
  static x = "PgRemoteDatabase";
}

export function drizzle(
  callback,
  config = {},
  _dialect = () => new PgDialect({ casing: config.casing })
) {
  const dialect = _dialect();
  const session = new PgRemoteSession(callback, dialect, undefined, {});
  return new PgRemoteDatabase(dialect, session, undefined);
}
// package.json
{ "perry": { "compilePackages": ["repro-pkg2"] } }
// probe_repro2.ts
import { drizzle } from 'repro-pkg2';
const db = drizzle(async (q, params, _m) => ({ rows: [] }));
console.log('db.dialect=', db.dialect);

Perry compile:

Undefined symbols for architecture arm64:
  "_node_modules_repro_pkg2_dialect_js__PgDialect_constructor", referenced from:
      _perry_closure_node_modules_repro_pkg2_index_js__1 in node_modules_repro_pkg2_index_js.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1

Bun output (correct):

[drizzle] _dialect typeof= function
[drizzle] dialect= PgDialect { casing: undefined } truthy= true
db.dialect= PgDialect { casing: undefined }

What I think is going on

The default-param arrow () => new PgDialect(...) becomes a closure (_perry_closure_..._index_js__1). Its emitted body calls _node_modules_repro_pkg2_dialect_js__PgDialect_constructor, but that symbol isn't being declared / emitted on the source-module side under compilePackages. Compare to a top-level new PgDialect(...) in the same module which links fine — the constructor emit only fails when the call site is inside a closure body.

Different but related symptom in drizzle-orm

In a real drizzle-orm 0.45.2 compile (drizzle-orm/pg-proxy/driver.js has the exact same shape — function drizzle(callback, config = {}, _dialect = () => new PgDialect({ casing: config.casing }))), the link succeeds, but at runtime typeof _dialect === 'number' and its value is 1.26e-321, which is f64::from_bits(269) where 269 is PgDialect's class id. Calling _dialect() returns undefined. As if the closure was replaced by the raw class-id-as-f64 of the class whose constructor it was supposed to call.

Diagnostic edits I tried in drizzle's compiled file didn't change the value:

  • Move the assignment out of the default param: if (_dialect === undefined) _dialect = () => new PgDialect(...) → still typeof _dialect === 'number'.
  • Replace with a regular function expression: function() { return new PgDialect(...) } — same.
  • Replace with a closure that doesn't reference PgDialect at all: function() { return 42 } → STILL typeof _dialect === 'number', same 1.26e-321 value.

Last point is the smoking gun: regardless of what we assign to _dialect, it always reads back as f64::from_bits(269) (PgDialect's class id). So storage for _dialect is being aliased to PgDialect — assignments to _dialect are no-op'd because perry's HIR / codegen statically pre-bound it to PgDialect.

I suspect a default-param desugar pass that:

  1. Sees _dialect = () => new PgDialect(...)
  2. "Constant-folds" the arrow to a reference to PgDialect itself (treating arrow-returning-new C(...) as alias-to-C)
  3. Stores PgDialect's class id (NaN-unboxed f64 raw bits) into _dialect's slot at function entry
  4. Marks _dialect as static-bound, so subsequent assignments (_dialect = anything else) get DCE'd

But I haven't located the offending pass.

Where I'd start digging

crates/perry-hir/src/lower_decl.rsbuild_default_param_stmts and the function-decl param-default desugar paths. Especially anything that pattern-matches () => new <Class>(...) as a special shape. Compare to lambda-body lowering for closures inside compilePackages packages.

crates/perry-codegen/src/... — closure emit when the body is new <ImportedClass>(...) and the source class lives in a sibling .js file under the same compilePackages package.

The link-failure form (_node_modules_repro_pkg2_dialect_js__PgDialect_constructor) is the easier entry point — it's a missing extern declaration that should be straightforward to trace from the closure's emitted IR back to the symbol it references.

Why it matters for #488

Blocks the drizzle + @perryts/postgres acceptance test (#488). After #585 (cross-module instanceof) and #586 (thenable assimilation) landed in v0.5.692, drizzle's db.insert(...) / db.select(...) paths now reach the dialect-call layer, where this bug surfaces because db.dialect is undefined.

Workaround

Patch drizzle to construct the dialect directly (no closure):

// in drizzle-orm/pg-proxy/driver.js
function drizzle(callback, config = {}) {
  const dialect = new PgDialect({ casing: config.casing });
  // ...
}

Confirmed working with this patch — db.dialect is set, insert proceeds past the dialect lookup. (Then runs into a separate hang in the actual query-execute path that I haven't dug into yet — likely the next blocker but distinct.)

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