codegen-js: native JS method dispatch and extern fn support

Any-typed receiver method calls now emit obj.method(args) directly
instead of requiring native_js_call. client.auth.signInWithOtp(p)
compiles to client["auth"].signInWithOtp(p) -- no escape hatch needed.

Field access emits obj["field"] (direct bracket notation) instead of
el_get_field, so prototype-inherited JS properties resolve correctly.
el_get_field's hasOwnProperty guard was silently returning null for
real JS objects with inherited fields (Supabase auth, DOM APIs, etc).

El runtime shortform methods (append, len, get, map_get, map_set)
still use the existing method(obj, args) convention for backward compat.

ExternFn statements emit a comment and are excluded from top-level
statement codegen -- the extern declaration tells the compiler the
function exists in the JS environment without emitting a body.
This commit is contained in:
Will Anderson
2026-05-04 10:58:07 -05:00
parent 7b60d94b8a
commit 01fee9396a
+53 -13
View File
@@ -86,6 +86,26 @@ fn js_binop(op: String) -> String {
op
}
// Known El runtime method names
//
// These are the method shortforms exported by el_runtime.js and used by the
// El C-backend convention of `obj.method(args)` -> `method(obj, args)`.
// Any method name NOT in this set is treated as a native JS method call on the
// receiver object, emitting `obj.method(args)` directly.
//
// This is the mechanism that makes `client.auth.signInWithOtp(payload)` work
// without `native_js_call`: the receiver is Any-typed, the method is unknown
// to El, so codegen emits the JS call directly.
fn js_is_el_method(name: String) -> Bool {
if str_eq(name, "append") { return true }
if str_eq(name, "len") { return true }
if str_eq(name, "get") { return true }
if str_eq(name, "map_get") { return true }
if str_eq(name, "map_set") { return true }
false
}
// Async function tracking
//
// Functions decorated with @async are recorded here. Any call to a known-async
@@ -419,16 +439,27 @@ fn js_cg_expr(expr: Map<String, Any>) -> String {
}
if func_kind == "Field" {
// El's `obj.method(args)` becomes `method(obj, args)` same
// convention as the C backend. The runtime exports method
// shortforms (append, len, get, map_get, map_set) that match.
let obj = func["object"]
let field: String = func["field"]
let obj_c: String = js_cg_expr(obj)
if arity > 0 {
return field + "(" + obj_c + ", " + args_c + ")"
// If the method is a known El runtime shortform, keep the El
// convention: `method(obj, args)`. This preserves backward
// compatibility with list.append(x), map.map_get(k), etc.
if js_is_el_method(field) {
if arity > 0 {
return field + "(" + obj_c + ", " + args_c + ")"
}
return field + "(" + obj_c + ")"
}
return field + "(" + obj_c + ")"
// Unknown method emit as a native JS method call on the
// receiver. This handles Any-typed values (third-party library
// objects, DOM elements, Promises, etc.) without requiring
// native_js_call. Example: `client.auth.signInWithOtp(payload)`
// emits `client["auth"].signInWithOtp(args_c)`.
if arity > 0 {
return obj_c + "." + field + "(" + args_c + ")"
}
return obj_c + "." + field + "()"
}
let fn_c: String = js_cg_expr(func)
@@ -436,13 +467,13 @@ fn js_cg_expr(expr: Map<String, Any>) -> String {
}
if kind == "Field" {
// El's `obj.foo` becomes JS `obj["foo"]` works on plain objects
// (maps) and on JS objects with prototype. el_get_field is a
// runtime helper for callers that want EL_NULL on missing keys.
// El's `obj.foo` becomes JS `obj["foo"]` direct bracket access.
// This works for plain El map objects AND for real JS objects with
// prototype-inherited properties (DOM elements, third-party library
// objects, Promises, etc.). el_get_field used hasOwnProperty which
// silently returned null for inherited props, breaking e.g. client.auth.
//
// If the object is a Try (nil-propagation postfix `obj?`), use JS
// optional chaining: `obj?.["foo"]` returns undefined (null) if obj
// is null/undefined, otherwise accesses the field.
// Nil-propagation: `obj?.foo` emits `(obj)?.["foo"] ?? null`.
let obj = expr["object"]
let field: String = expr["field"]
let obj_kind: String = obj["expr"]
@@ -452,7 +483,7 @@ fn js_cg_expr(expr: Map<String, Any>) -> String {
return "(" + inner_c + ")?.[" + js_str_lit(field) + "] ?? null"
}
let obj_c: String = js_cg_expr(obj)
return "el_get_field(" + obj_c + ", " + js_str_lit(field) + ")"
return obj_c + "[" + js_str_lit(field) + "]"
}
if kind == "Index" {
@@ -715,6 +746,14 @@ fn js_cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [St
if kind == "TypeDef" { return declared }
if kind == "EnumDef" { return declared }
if kind == "Import" { return declared }
// ExternFn: the function exists in the JS environment (loaded via <script>
// tag or the module context). Emit a comment so the generated file is
// self-documenting, but no JS function body the implementation is external.
if kind == "ExternFn" {
let ename: String = stmt["name"]
js_emit_line(indent + "// extern fn " + ename + " — provided by the JS environment")
return declared
}
if kind == "CgiBlock" {
// CGI blocks compile to a no-op + warning comment in JS target.
// The runtime cgi identity is server-side; UI code is not a CGI
@@ -913,6 +952,7 @@ fn js_is_top_level_decl(stmt: Map<String, Any>) -> Bool {
if kind == "Import" { return true }
if kind == "CgiBlock" { return true }
if kind == "ServiceBlock" { return true }
if kind == "ExternFn" { return true }
false
}