Skip to content

fix(dynamic-import): resolve function/closure-local const string specifiers (#1725) - #1727

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-fix-1725-dynamic-import-local-const
May 25, 2026
Merged

fix(dynamic-import): resolve function/closure-local const string specifiers (#1725)#1727
proggeramlug merged 1 commit into
mainfrom
worktree-fix-1725-dynamic-import-local-const

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Fixes #1725.

Problem

import { logger } from 'hono/logger' fails to compile under Perry-native because hono/dist/utils/color.js contains:

async function getColorEnabledAsync() {
  const cfWorkers = "cloudflare:workers";
  const isNoColor = navigator?.userAgent === "Cloudflare-Workers"
    ? await (async () => {
        try { return "NO_COLOR" in ((await import(cfWorkers)).env ?? {}); }
        catch { return false; }
      })()
    : !getColorEnabled();
  return !isNoColor;
}

cfWorkers is a function-local const string literal (captured into a nested IIFE), used as a dynamic import() specifier inside the classic optional-dep try/catch. Perry's compile-time const-folder (collect_module_const_locals) only collected module-level consts — on the now-incorrect assumption that a dynamic import always runs in module-init scope — so it rejected the local const with a hard error:

Error: dynamic import() in module .../color.js: path argument references a binding
that is not a module-level const initialized to a literal ...

Relationship to the dynamic-import series

This is a targeted slice of #1674 (the variable/non-literal specifier axis of the #1672/#1673/#1674 series). It is not the runtime-dispatch gap (#1672/#1673) — the specifier here is statically a string literal, just function-scoped. Fully-dynamic / computed specifiers remain #1674's scope.

Fix

Collect non-mutated const x = <init> bindings from the full scope set that for_each_dynamic_import_mut already walks — module init, every function / method / getter / setter / static-method body, and field + global initializers — descending into closure bodies. At this HIR stage closures are still inline and capture by the original LocalId, and the import walker descends into closure bodies, so a const declared in any enclosing scope now resolves at the import site. LocalIds are module-unique, so a single flat id → init map is unambiguous. The mutation-invalidation scan was extended symmetrically (it now also descends into closures) so a reassigned binding is still dropped.

cfWorkers then resolves to "cloudflare:workers", which has no module under Perry, so it rejects at runtime (a warning, not a compile error) — caught by the package's own try/catch, exactly matching Node's optional-dep behavior.

Validation

The #1725 acceptance program compiles and runs:

import { Hono } from 'hono'
import { logger } from 'hono/logger'
const app = new Hono()
app.use('*', logger())
app.get('/', (c) => c.text('ok'))

→ compiles (graceful Could not resolve import 'cloudflare:workers' warning), runs, app.use('*', logger()) works, routes register.

Tests

  • resolve_closure_local_const_specifier — a const inside a closure body resolves as a specifier.
  • collect_consts_invalidates_closure_mutation — a binding reassigned inside a closure is dropped (soundness).
  • Existing resolve_unresolved_param_local / resolve_unresolvable_local still reject (no regression); full perry-hir lib suite green.

…ifiers (#1725)

`import { logger } from 'hono/logger'` failed to compile because
`hono/dist/utils/color.js` does:

    async function getColorEnabledAsync() {
      const cfWorkers = "cloudflare:workers";
      ... try { return "NO_COLOR" in ((await import(cfWorkers)).env ?? {}); } catch { return false; }
    }

`cfWorkers` is a function-local const string literal (captured into a nested
IIFE), used as a dynamic `import()` specifier. The compile-time const-folder
(`collect_module_const_locals`) only collected MODULE-level consts, on the now-
wrong assumption that a dynamic import always evaluates in module-init scope —
so the local const was "not a module-level const initialized to a literal" and
the compile hard-errored.

Fix: collect non-mutated `const x = <init>` bindings from the full scope set
that `for_each_dynamic_import_mut` already walks (module init, every function /
method / getter / setter / static-method body, field + global initializers),
descending into closure bodies. At this HIR stage closures are still inline and
capture by the original `LocalId`, and the import walker descends into closure
bodies, so a const declared in any enclosing scope now resolves at the import
site. `LocalId`s are module-unique, so a single flat id->init map is unambiguous.
The mutation-invalidation scan was extended symmetrically (it now also descends
into closures) so a reassigned binding is still dropped.

`cfWorkers` resolves to `"cloudflare:workers"`, which has no module under Perry
and so reject-at-runtime (a warning, not a hard error) — caught by the package's
own `try/catch`, exactly matching Node's optional-dep idiom. Verified end-to-end:
the #1725 acceptance program (`app.use('*', logger())`) now compiles and runs.

This is a targeted slice of the dynamic-import series (#1674's variable-specifier
axis); fully-dynamic / computed specifiers remain out of scope.

Tests: resolve_closure_local_const_specifier, collect_consts_invalidates_closure_mutation
(perry-hir). Existing resolve_unresolved_param_local / resolve_unresolvable_local
still reject as before.
@proggeramlug
proggeramlug merged commit 65de8ec into main May 25, 2026
10 checks passed
@proggeramlug
proggeramlug deleted the worktree-fix-1725-dynamic-import-local-const branch May 25, 2026 03:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hono/logger fails to compile: dynamic import() with non-literal path in hono/utils/color.js

1 participant