Skip to content

Module-graph dead-code elimination: named-import tree-shaking + build-time env-branch folding for compilePackages #2309

Description

@proggeramlug

Summary

Perry's compile pipeline is resolve → compile every import-reachable module → lower → codegen. It is missing the module-graph optimization phase that every JS toolchain (esbuild, rollup, webpack, Closure) places between resolution and emit. As a result, Perry eagerly parses, lowers, and codegens dead transitive code — code that the program's executed paths never reach — and hard-errors when that dead code contains a runtime new Function, an unimplemented platform API, or a binding that trips a codegen gap.

This is a general compilePackages problem, not an ink/React problem. It will bite any large npm tree (Hono, Drizzle, Effect, …). It was surfaced most vividly by the #348 ink recheck, which is why the evidence below is ink-based.

Evidence (ink 7.0.4 + react 19.2.6, perry 0.5.1028)

A 5-line ink hello-world (render(<Text>hi</Text>)) fails to compile. Every wall is in code the hello-world never executes:

  1. new Function refusal in es-toolkit/dist/compat/string/template.mjs:131 (lodash _.template). ink imports exactly one symbol: import { throttle } from 'es-toolkit/compat'. es-toolkit/compat/index.mjs is a pure re-export barrel (verified: only import … from './x.mjs' lines + one export { … }, zero top-level side effects). The barrel re-exports template, so Perry compiles it and correctly refuses its genuinely-runtime new Function (body assembled from runtime data incl. Date.now() → the RuntimeUnknown bucket in eval_classifier.rs). template is never reachable from throttle.

  2. With PERRY_ALLOW_EVAL=1: next wall is react-reconciler / yoga-layout JS not auto-compiled (fine once listed in compilePackages).

  3. With those listed: zlib.Z_DEFAULT_WINDOWBITS not implemented (Compile-time error for unimplemented Node / Web APIs #463) — from ws's permessage-deflate.

  4. With PERRY_ALLOW_UNIMPLEMENTED=1: a real codegen bugError: Undefined variable in update expression: randomPoolPointer from ws/lib/sender.js (module-level let randomPoolPointer = … in a cjs-wrapped module, mutated via ++ inside a nested fn, is misclassified as an implicit local; the update-expression path then can't resolve it).

Walls 3 and 4 both come from ws, which ink loads only behind:

// ink/build/ink.js
if (process.env['DEV'] === 'true') {
  await import('./devtools.js');   // pulls react-devtools-core + ws
}

A runtime-env-gated dynamic import(). With process.env.DEV undefined at build, that branch is statically dead — and ws, react-devtools-core, the zlib gap, and the randomPoolPointer bug all vanish with it.

The two phases to add

Phase 1 — Named-import reachability (lazy barrel resolution / tree-shaking)

Don't follow a re-export edge unless the specific re-exported binding is actually imported. Re-export barrels are pure by construction, so this needs no side-effect analysis — it's the safe, high-value entry point.

  • Kills wall Support custom menu bar items #1 (the headline new Function refusal).
  • Shrinks every compilePackages binary (single-file-native value prop).
  • Granularity: named-export level within a module, starting with the trivially-safe re-export-only barrel case.

Phase 2 — Build-time process.env.* folding + dead-branch / dead-import() elimination

Const-propagate build-time-known process.env.X (default: undefined unless set), fold the resulting dead if/ternary branches, and drop import()/require() calls that become unreachable.

Explicit non-goal (the tempting wrong call)

Do not make refusals degrade to link-stubs by default, or lean on PERRY_ALLOW_EVAL + PERRY_ALLOW_UNIMPLEMENTED as the path. That is compile-then-stub dead code: still pays parse+lower+codegen on never-run code, still exposes every codegen bug in unreachable paths (the randomPoolPointer crash stays reachable by the compiler), and ships latent runtime traps. Escape hatches are a debug-bisection tool, not the default. DCE removes the problem instead of papering over it.

Scope boundary

This phase makes ink (and other npm trees) compile; it does not make ink render. ink's render path genuinely calls yoga-layout (WASM) — not dead code, DCE can't strip it, and WASM-in-binary fights single-file-native. perry/tui (taffy, Rust-native flexbox) remains the product TUI path. See #348.

Acceptance

  • Phase 1: importing one symbol from a pure re-export barrel compiles only that symbol's reachable subgraph; a refusal-worthy sibling export (e.g. es-toolkit template) is never compiled. Regression test in test-files/.
  • Phase 2: a process.env.X-gated dead branch containing an import()/require() is eliminated, and its target subtree is not compiled. Regression test.
  • ink hello-world compiles with no escape-hatch env vars set (yoga link/run still out of scope — gate render separately under Compile ink (React-based TUI framework) end-to-end via perry.compilePackages #348).
  • Binary-size / module-count delta logged on a representative compilePackages target.

Related

Activity

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