Skip to content

fix: normalize "." and ".." in resolved module paths to dedupe modules#1977

Open
adrian-niculescu wants to merge 1 commit into
NativeScript:mainfrom
adrian-niculescu:fix/esm-relative-path-dedup
Open

fix: normalize "." and ".." in resolved module paths to dedupe modules#1977
adrian-niculescu wants to merge 1 commit into
NativeScript:mainfrom
adrian-niculescu:fix/esm-relative-path-dedup

Conversation

@adrian-niculescu

@adrian-niculescu adrian-niculescu commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

ResolveModuleCallback builds the on-disk path for a relative specifier without collapsing . and .. segments, so the same file imported as ./x from /a/b and as ../x from /a/b/c lands under two different registry keys. stat() resolves the segments so both locate the file, but the differing keys make V8 compile and evaluate the module twice: two instances with separate module state, broken cyclic resolution, and an import.meta.dirname of /a/b/c/.. for the .. spelling.

This normalizes the resolved path before it becomes the registry key, the same way the HTTP branch already canonicalizes through CanonicalizeHttpUrlKey. The pass is lexical (matching how specifiers are joined, not a realpath) and applies to every on-disk candidate kind; it is a no-op for already-canonical paths.

Test: a test-app spec imports one module as ./counter.mjs and ../counter.mjs and asserts they share one instance (===, and a counter incremented to 2). It fails before the change and passes after.

Summary by CodeRabbit

  • Bug Fixes
    • Improved module loading so the same module is treated consistently even when reached through different relative paths.
    • Shared state now updates reliably across multiple import locations, preventing duplicate copies of the same counter.
    • Added coverage to verify that imports from different paths observe the same live state.

ResolveModuleCallback builds the on-disk path for a relative specifier without collapsing "." and ".." segments, so the same file imported as "./x" from /a/b and as "../x" from /a/b/c lands under two different registry keys. stat() resolves the segments so both locate the file, but the differing keys make V8 compile and evaluate the module twice: two instances with separate state, and import.meta.dirname becomes "/a/b/c/.." for the ".." spelling. Normalize the resolved path before it becomes the registry key, the same way the HTTP branch canonicalizes through CanonicalizeHttpUrlKey. The pass is lexical and runs for every on-disk candidate kind, and is a no-op for already-canonical paths. Adds a test-app spec that imports one module as "./counter.mjs" and "../counter.mjs" and asserts a single shared instance.
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 322d5fbf-a6ab-471f-be3f-393ae43c2a92

📥 Commits

Reviewing files that changed from the base of the PR and between 92c2654 and e4e84cd.

📒 Files selected for processing (5)
  • test-app/app/src/main/assets/app/esm-dedup/counter.mjs
  • test-app/app/src/main/assets/app/esm-dedup/nested/viaParentDir.mjs
  • test-app/app/src/main/assets/app/esm-dedup/viaSameDir.mjs
  • test-app/app/src/main/assets/app/tests/testESModules.mjs
  • test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp

📝 Walkthrough

Walkthrough

Adds NormalizeDotSegments canonicalization to ResolveModuleCallback in ModuleInternalCallbacks.cpp so that paths with ./ or ../ segments resolve to the same canonical key. Three new fixture modules (counter.mjs, viaSameDir.mjs, nested/viaParentDir.mjs) and a Jasmine spec verify that two imports using different relative paths share one module instance.

Changes

ESM Deduplication Fix and Tests

Layer / File(s) Summary
Path canonicalization in ResolveModuleCallback
test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp
After a filesystem candidate is confirmed to exist, absPath is rewritten through NormalizeDotSegments so that dot-segment path variants (./x, ../x) map to one canonical path before cache lookup and module registration.
ESM dedup fixture modules and Jasmine spec
test-app/app/src/main/assets/app/esm-dedup/counter.mjs, test-app/app/src/main/assets/app/esm-dedup/viaSameDir.mjs, test-app/app/src/main/assets/app/esm-dedup/nested/viaParentDir.mjs, test-app/app/src/main/assets/app/tests/testESModules.mjs
counter.mjs exports a shared state object and increment(); viaSameDir.mjs and nested/viaParentDir.mjs each import the counter via different relative paths and call increment() on load; the Jasmine test imports both and asserts object identity of seenState and count === 2.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • NativeScript/android#1976: Directly modifies the same ModuleInternalCallbacks.cpp module-resolution path, making it a prerequisite or sibling change to the dot-segment normalization applied here.

Suggested reviewers

  • NathanWalker

Poem

🐇 Hop along the module trail,
Two paths once led to tales,
But now one path, one truth to tell —
NormalizeDotSegments rings the bell!
The counter counts to two with glee,
Deduplicated, finally free! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: normalizing dot segments in resolved module paths to dedupe modules.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@adrian-niculescu

Copy link
Copy Markdown
Contributor Author

@NathanWalker, (and @coderabbitai), I checked the iOS runtime and it doesn't need an equivalent of this change. Vanilla NativeScript/ios already strips . and .. out of a resolved module path before that path becomes the registry key, so the ./x vs ../x double-instantiation this PR fixes on Android can't happen there.

It's all in ModuleInternalCallbacks.mm:

So canonicalization on iOS happens earlier and in more places than the single pass here, and both sides end up keying the registry by the same canonical path. No parity PR needed on iOS.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@adrian-niculescu Thanks for the thorough cross-platform investigation. The breakdown is very clear:

  • iOS (ModuleInternalCallbacks.mm): canonicalization via stringByStandardizingPath happens at multiple points (path normalization helper, candidate base construction, joined relative path, and registry key assignment), and it's filesystem-aware (resolves symlinks, not just lexical).
  • Android (this PR): a single lexical NormalizeDotSegments pass applied to the confirmed on-disk candidate path, just before it becomes the registry key.

Both approaches converge on the same canonical path as the registry key, so ./x and ../x correctly deduplicate on both platforms. No iOS follow-up needed — noted.


✏️ Learnings added
Learnt from: adrian-niculescu
URL: https://github.com/NativeScript/android/pull/1977

Timestamp: 2026-06-24T11:37:48.132Z
Learning: In NativeScript, the iOS runtime (`NativeScript/ios`) already prevents `./x` vs `../x` module double-instantiation without any additional fix. `NormalizePath()` in `NativeScript/runtime/ModuleInternalCallbacks.mm` (around L50-L66) uses `stringByStandardizingPath` which canonicalizes `.` and `..` segments by consulting the real filesystem (folds symlinks too, not just a lexical rewrite). This normalization is applied at multiple call sites: candidate base during resolution (~L1161), joined relative path when building candidates (~L967-976), and the final `absPath` just before it is used as the `g_moduleRegistry` key (~L1317). The Android PR `#1977` achieves the same dedup via a single lexical `NormalizeDotSegments` pass on the confirmed on-disk path before it becomes the registry key. No parity PR is needed on iOS.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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.

1 participant