Skip to content

fix(metro): preserve upstream transformer initialization failures#690

Merged
samchon merged 2 commits into
masterfrom
fix/metro-upstream
Jul 15, 2026
Merged

fix(metro): preserve upstream transformer initialization failures#690
samchon merged 2 commits into
masterfrom
fix/metro-upstream

Conversation

@samchon

@samchon samchon commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Bundle b8 — Metro upstream transformer errors

This bundle hardens @ttsc/metro's upstream Babel-transformer loader so a broken-but-installed transformer is no longer masked as absence.

Issues

Problem

resolveUpstreamTransformer treated every exception from requireing an upstream candidate as "module not present." An installed transformer that throws during top-level initialization, has a missing peer/transitive dependency, or rejects the current runtime ABI was silently discarded:

  • an explicit upstreamTransformer failure surfaced only the generic Could not load the configured upstream transformer, dropping the original message and stack;
  • auto-detection could skip the broken candidate and select a later one that does not match the active Expo/RN stack.

Fix

tryRequire now splits resolution from execution. require.resolve never runs third-party code, so a MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND there proves the requested candidate itself is genuinely absent — reported as undefined so automatic probing continues to the next optional peer. Any error thrown while executing the (resolvable) module body is a real initialization failure and is preserved with its original message and stack as the cause of a @ttsc/metro wrapper. A candidate's own missing transitive dependency surfaces that dependency, not a false "absent candidate."

Tests

Production-loader regressions under tests/test-metro/src/features/upstream, run with pnpm --filter @ttsc/test-metro start -- --include=upstream:

  • genuinely-absent explicit path stays the plain absence message, no cause;
  • explicit init-throw preserves the original diagnostic + cause + stack;
  • missing transitive dependency is named, not misreported as absence;
  • auto-detect does not fall through to a later candidate when an earlier one is broken.

Docs: packages/metro/README.md documents the absence-vs-broken distinction.

Split module resolution from execution in the upstream loader so a candidate
that resolves but throws during initialization (top-level throw, missing
peer/transitive dependency, or runtime-ABI rejection) is no longer masked as
absence. `require.resolve` failures (MODULE_NOT_FOUND) prove the requested
candidate itself is absent and stay non-fatal during auto-detection; any error
from executing the module body is preserved with its original message and stack
via the Error `cause`. Explicit configured failures surface the original
diagnostic instead of the generic "could not load" message, and auto-detection
no longer falls through to a mismatched candidate when an earlier one is broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYCpGEc14zxD99ZPPzAnpz
@samchon

samchon commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Self-Review round 1

Verified the committed implementation against the RA-15 acceptance criteria and traced the consequence surface of splitting require.resolve from require.

Finding (correctness regression introduced by the split). isModuleNotFound classifies only MODULE_NOT_FOUND/ERR_MODULE_NOT_FOUND as absence. But the first candidate, @expo/metro-config/babel-transformer, is a package subpath. When @expo/metro-config is installed at a version whose babel-transformer subpath is not exported (version skew), Node's require.resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED, not MODULE_NOT_FOUND. The committed code rethrows that and auto-detection hard-fails with 'installed but failed to initialize' instead of falling through to the React Native candidate.

This is a regression versus the old swallow-everything loader and violates the issue's criterion that genuine absence stays non-fatal during automatic probing. A resolution-time error can only concern the requested specifier (resolve never executes the module body), and ERR_PACKAGE_PATH_NOT_EXPORTED means the requested candidate entry point is not available here — that is absence, not a broken initialization. Empirically confirmed: absent package -> MODULE_NOT_FOUND; installed package, missing subpath export -> ERR_PACKAGE_PATH_NOT_EXPORTED.

Remediation plan.

  1. Broaden the absence classifier (rename isModuleNotFound -> isCandidateAbsent) to also treat ERR_PACKAGE_PATH_NOT_EXPORTED as absence, with an updated doc explaining that every resolution error concerns the requested specifier and these codes all mean 'this entry point is not available here'. Execution-time failures (top-level throw, missing transitive dependency) are untouched — they still surface with cause.
  2. Add a production-path regression under tests/test-metro/src/features/upstream that drives the real loader with an installed package whose requested subpath is not exported and asserts it is reported as absence (no cause), the boundary twin of the init-failure cases.
  3. Reconcile the README wording so 'genuinely not installed' includes a missing/non-exported entry point.

Applying now.

The first auto-detection candidate, @expo/metro-config/babel-transformer, is a
package subpath. When @expo/metro-config is installed but its babel-transformer
subpath is not exported (Expo/React Native version skew), Node's require.resolve
throws ERR_PACKAGE_PATH_NOT_EXPORTED, not MODULE_NOT_FOUND. The prior split
classified only MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND as absence, so such a
candidate hard-failed auto-detection instead of falling through to the next
transformer.

Broaden the absence classifier (isModuleNotFound -> isCandidateAbsent) to also
treat ERR_PACKAGE_PATH_NOT_EXPORTED as absence. A resolution-time error can only
concern the requested specifier (resolve never executes the module body), and
this code means the requested entry point is unavailable here -- genuine
absence, kept non-fatal during probing. Execution-time failures (top-level
throw, missing transitive dependency) are untouched and still surface with
cause. Adds a production-path regression driving the real loader with an
installed package whose requested subpath is not exported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYCpGEc14zxD99ZPPzAnpz
@samchon

samchon commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Self-Review round 2 — clean

Re-inspected the full changed surface after the round-1 fix; no sound improvement or new issue found.

  • Classifier composition. isCandidateAbsent now returns undefined for MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND / ERR_PACKAGE_PATH_NOT_EXPORTED, which composes cleanly with the existing auto-detect loop (absence -> continue) and the explicit-path branch (absence -> 'could not load'). Execution-time failures (top-level throw, missing transitive dependency, non-absence resolution errors) still surface with cause — the resolve/execute split is intact.
  • Explicit-path semantics. A configured subpath whose export target is missing now reports the plain 'could not load' guidance rather than a wrapped init failure; that is correct — it is a resolution-time absence of the requested entry, and it never masks an execution-time transitive failure (those still surface).
  • Coverage. Acceptance criteria 1-7 are each pinned: genuinely-absent explicit path, non-exported subpath (new), explicit init-throw with cause+stack, missing transitive dependency, auto-detect no-fall-through, priority-order fall-through, and terminal install guidance. All but the injected auto-detect-init case run through the production require loader.
  • Hygiene. No stale isModuleNotFound references; tsc --noEmit -p tests/test-metro/tsconfig.json and pnpm --filter @ttsc/metro build (tsc emit) both pass; README reconciled to 'not available (not installed or subpath not exported)'.

Self-review complete at 2 rounds (1 finding remediated, 1 clean pass).

@samchon

samchon commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Final verification — green

RA-15 (#685) implemented and self-reviewed (2 rounds). Local, package-scoped verification on HEAD e4d77b4a8:

pnpm --filter @ttsc/metro build          # tsc emit + rollup, OK
pnpm --filter @ttsc/test-metro start -- --include=upstream   # 15/15 Success
npx tsc --noEmit -p tests/test-metro/tsconfig.json           # exit 0

The upstream suite covers every acceptance criterion, all but the injected auto-detect-init case running through the production require loader:

  • test_upstream_genuinely_absent_candidate_stays_non_fatal — absent explicit path -> 'could not load', no cause
  • test_upstream_unexported_subpath_stays_non_fatal — installed package, non-exported subpath (ERR_PACKAGE_PATH_NOT_EXPORTED) -> absence, keeps auto-detect fall-through under version skew
  • test_upstream_configured_init_failure_preserves_cause — explicit init-throw preserves message + cause + stack
  • test_upstream_missing_transitive_dependency_reported — missing transitive dep named, not misreported as absent
  • test_upstream_auto_detect_init_failure_does_not_fall_through — broken earlier candidate surfaces, no fall-through to legacy
  • plus the pre-existing priority-order, empty-path, and no-upstream-installed cases

Per the campaign HARD RULES I did not run whole-repo pnpm test or pnpm format; the canonical pnpm build/pnpm test belong to the lead's recheck. Marking ready for review. Not merging.

@samchon
samchon marked this pull request as ready for review July 15, 2026 12:59
@samchon samchon changed the title fix(8): metro upstream transformer errors fix(metro): preserve upstream transformer initialization failures Jul 15, 2026
@samchon
samchon merged commit fd33764 into master Jul 15, 2026
2 of 49 checks passed
@samchon
samchon deleted the fix/metro-upstream branch July 15, 2026 13:22
@samchon

samchon commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Squash-merged

Bundle b8 (RA-15, #685) squash-merged into master as a single fix(metro) commit; linked issues auto-close from the PR body.

  • Merge commit: fd33764b2446e0a632471662de2088fb23781b42
  • Merge-commit CI cancelled by exact SHA per campaign discipline (no wasted runners on the suspended-CI campaign).

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.

fix(metro): preserve upstream transformer initialization failures

1 participant