Skip to content

fix(bundler): erase ORM-shadowing class fields via project-dir tsconfig#5979

Merged
killagu merged 1 commit into
nextfrom
fix/bundler-class-field-shadowing
Jun 20, 2026
Merged

fix(bundler): erase ORM-shadowing class fields via project-dir tsconfig#5979
killagu merged 1 commit into
nextfrom
fix/bundler-class-field-shadowing

Conversation

@killagu

@killagu killagu commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Motivation

Declared-but-uninitialized TypeScript class fields (e.g. createdAt: Date; on a leoric Bone model) compiled with useDefineForClassFields: true become native own undefined properties at runtime. They shadow the get/set accessors ORMs install on the prototype for decorated attributes, so writes never reach the setter and the column is silently dropped on INSERT (observed as a missing gmt_create).

The bundle must compile these with useDefineForClassFields: false (matching how Egg apps build with target <= ES2021) so the bare declarations are erased and the prototype accessors stay effective.

Why the obvious placement does not work

@utoo/pack (Turbopack) resolves the governing tsconfig from the project dir (the projectPath passed to build()) — not the output dir, and not via per-file find-up to the nearest tsconfig. Verified against @utoo/pack 1.4.13/1.4.14 with an isolated repro:

tsconfig location shadowing field
output dir ❌ kept
nearer the source than projectPath ❌ kept (ignored)
projectPath/tsconfig.json ✅ erased

This is not an upstream bug — see utooland/utoo#2450 (confirmed and closed COMPLETED): the option works, only placement matters. Bundler passed projectPath = app baseDir, whose own tsconfig extends @eggjs/tsconfig (target: ES2024useDefineForClassFields defaults to true), so the field survived.

Scope

  • PackRunner writes the compiler tsconfig (experimentalDecorators, emitDecoratorMetadata, target, useDefineForClassFields:false) into the project dir instead of the output dir.
  • Bundler passes projectPath = the generated entry dir (build-managed .egg-bundle/entries) and rootPath = app baseDir, so the compiler tsconfig is the one @utoo/pack resolves; the app's own tsconfig is neither read nor overwritten, and app sources + node_modules above the entry dir still resolve.

Diff is limited to tools/egg-bundler.

Test evidence

A real @utoo/pack build regression test (decorator-free leoric-like model, so no @swc/helpers needed) asserts:

  • the bare field is erased (empty class body);
  • the prototype setter side effect survives — toSQLValues() keeps the column (runtime proof by executing the bundle under node);
  • the app's own tsconfig is left untouched.

It is intentionally a real build so a future @utoo/pack upgrade or a tsconfig-placement regression is caught (a mock cannot). Unit/integration tests are updated for the new tsconfig location and a guard pins projectPath = entry dir.

pnpm --filter=@eggjs/egg-bundler run test is green (local-only macOS /private/var symlink failures in EntryGenerator/ManifestLoader are pre-existing and unrelated to this change).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed ORM class field shadowing during bundle builds so derived classes no longer break base createdAt getter/setter behavior.
    • Improved bundling TypeScript configuration placement and behavior: tsconfig.json is generated in the project/entry directory, includes the expected compiler options (including useDefineForClassFields: false), and avoids clobbering an existing configuration unexpectedly.
  • Tests / Documentation

    • Updated and added regression and integration tests, plus refreshed determinism notes to reflect the new tsconfig.json location and pre-write ordering.

Copilot AI review requested due to automatic review settings June 20, 2026 13:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e6c6bcf8-2393-4ca2-b2dc-6a3071a310a9

📥 Commits

Reviewing files that changed from the base of the PR and between 55e6a9a and 0396df0.

📒 Files selected for processing (7)
  • tools/egg-bundler/src/lib/Bundler.ts
  • tools/egg-bundler/src/lib/PackRunner.ts
  • tools/egg-bundler/test/Bundler.test.ts
  • tools/egg-bundler/test/PackRunner.test.ts
  • tools/egg-bundler/test/classFieldShadowing.realbuild.test.ts
  • tools/egg-bundler/test/deterministic.test.ts
  • tools/egg-bundler/test/integration.test.ts
✅ Files skipped from review due to trivial changes (1)
  • tools/egg-bundler/test/deterministic.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • tools/egg-bundler/test/Bundler.test.ts
  • tools/egg-bundler/src/lib/Bundler.ts
  • tools/egg-bundler/test/classFieldShadowing.realbuild.test.ts
  • tools/egg-bundler/test/PackRunner.test.ts
  • tools/egg-bundler/src/lib/PackRunner.ts
  • tools/egg-bundler/test/integration.test.ts

📝 Walkthrough

Walkthrough

PackRunner now writes COMPILER_TSCONFIG (adding useDefineForClassFields: false) into projectPath instead of outputDir, and Bundler passes entries.entryDir as projectPath with absBaseDir as the rootPath fallback. All unit, integration, and determinism tests are updated to match, and a new real-build regression test verifies the class-field shadowing fix end-to-end.

Changes

tsconfig placement and class-field shadowing fix

Layer / File(s) Summary
PackRunner tsconfig relocation and Bundler wiring
tools/egg-bundler/src/lib/PackRunner.ts, tools/egg-bundler/src/lib/Bundler.ts
COMPILER_TSCONFIG constant replaces OUTPUT_TSCONFIG, adding useDefineForClassFields: false and decorator metadata options; PackRunner.run() writes package.json to outputDir and tsconfig.json to projectPath with safety checks for non-build-managed directories; Bundler passes entries.entryDir as projectPath and mergedPack?.rootPath ?? absBaseDir as rootPath.
Bundler unit test updates
tools/egg-bundler/test/Bundler.test.ts
EntryGenerate mock now returns both workerEntry and entryDir; beforeEach derives entryDir under the temp app directory and configures mocks to return both properties pointing to real writable paths.
PackRunner unit test updates
tools/egg-bundler/test/PackRunner.test.ts
Unit tests assert tsconfig.json lands in projectPath before buildFunc, confirm absence from outputDir, verify useDefineForClassFields: false and decorator metadata, check rootPath defaults and forwarding, and remove tsconfig.json from expected output files.
Integration test updates
tools/egg-bundler/test/integration.test.ts
Tests remove tsconfig.json from result.files and bundle-manifest chunk assertions, read tsconfig.json from the entry directory during build, verify useDefineForClassFields: false and correct projectPath/rootPath forwarding.
Determinism test comment update
tools/egg-bundler/test/deterministic.test.ts
T17 determinism test clarifies which directories package.json and tsconfig.json are pre-written into by PackRunner.
Real-build class-field shadowing regression test
tools/egg-bundler/test/classFieldShadowing.realbuild.test.ts
New end-to-end test builds a MODEL fixture (base class getter/setter + subclass field declaration) via PackRunner, asserts app tsconfig.json is not overwritten, verifies no shadowing field in emitted JS, and confirms toSQLValues() output via node worker.js.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • eggjs/egg#5886: Both PRs update tools/egg-bundler/test/deterministic.test.ts to align determinism expectations around where package.json and tsconfig.json are pre-written during bundling.

Suggested reviewers

  • fengmk2

Poem

🐰 A tsconfig went hopping to the wrong little den,
Now it lives in entries/ — fixed up again!
useDefineForClassFields: false, the setter won't hide,
The createdAt getter can run with its pride.
Hop hop, no more shadowing — the ORM's alright! 🥕

🚥 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 directly and accurately summarizes the main change: fixing ORM-shadowing class fields by moving the tsconfig to the project directory with useDefineForClassFields: false.
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.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bundler-class-field-shadowing

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 and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the egg-bundler to write the compiler tsconfig.json into the build-managed project entry directory instead of the output directory. This ensures that @utoo/pack correctly resolves the configuration, enabling decorator metadata and setting useDefineForClassFields to false to prevent class-field shadowing issues with ORMs like Leoric without modifying the user's own configuration. The feedback recommends adding a safeguard to prevent accidentally overwriting a user's tsconfig.json, resolving rootPath to an absolute path to handle relative paths safely, and using process.execPath instead of 'node' in tests to guarantee the correct Node.js binary is executed.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread tools/egg-bundler/src/lib/PackRunner.ts Outdated
Comment on lines +105 to +106
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(path.join(projectPath, 'tsconfig.json'), JSON.stringify(COMPILER_TSCONFIG, null, 2));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Writing tsconfig.json directly to projectPath poses a high risk of silently overwriting a user's actual tsconfig.json if projectPath is not a build-managed directory (e.g., if PackRunner is used directly or configured differently). To prevent catastrophic data loss, we should add a safeguard that checks if an existing tsconfig.json was generated by egg-bundler (using a custom marker property) or if the directory is build-managed (under .egg-bundle) before overwriting it.

    const tsconfigPath = path.join(projectPath, 'tsconfig.json');
    let isUserTsconfig = false;
    try {
      const content = await fs.readFile(tsconfigPath, 'utf8');
      const parsed = JSON.parse(content);
      if (parsed && parsed._generatedBy !== 'egg-bundler' && !projectPath.includes('.egg-bundle')) {
        isUserTsconfig = true;
      }
    } catch (err) {
      // Ignore file not found or invalid JSON
    }
    if (isUserTsconfig) {
      throw new Error(`Refusing to overwrite user's tsconfig.json at ${projectPath}. Please ensure projectPath is a build-managed directory.`);
    }
    await fs.mkdir(projectPath, { recursive: true });
    await fs.writeFile(tsconfigPath, JSON.stringify({ ...COMPILER_TSCONFIG, _generatedBy: 'egg-bundler' }, null, 2));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 55e6a9a. PackRunner now refuses to overwrite an existing, differing tsconfig.json when projectPath is not a build-managed (.egg-bundle) directory, and throws a clear error instead of silently clobbering a user's config. Idempotent re-writes of our own identical output stay allowed (content-equality check), so no non-standard marker key is added to tsconfig.json.

Comment thread tools/egg-bundler/src/lib/Bundler.ts Outdated
// stays at the app baseDir (or a caller-supplied monorepo root) so the
// app sources and node_modules above the entry dir still resolve.
projectPath: entries.entryDir,
rootPath: mergedPack?.rootPath ?? absBaseDir,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If mergedPack?.rootPath is configured as a relative path, passing it directly to PackRunner can cause resolution issues depending on the current working directory. It is safer to resolve it relative to absBaseDir to guarantee it is an absolute path.

Suggested change
rootPath: mergedPack?.rootPath ?? absBaseDir,
rootPath: mergedPack?.rootPath ? path.resolve(absBaseDir, mergedPack.rootPath) : absBaseDir,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 55e6a9arootPath is now resolved with path.resolve(absBaseDir, mergedPack.rootPath) so a relative value no longer depends on cwd.


// Runtime proof: executing the bundle, the setter ran and the column is
// present in toSQLValues() (the INSERT payload).
const { stdout } = await execFileAsync('node', [path.join(outputDir, 'worker.js')], { cwd: outputDir });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using 'node' directly in execFileAsync relies on the system PATH and might execute a different Node.js binary than the one running the tests. Using process.execPath is more robust and guarantees cross-platform consistency.

Suggested change
const { stdout } = await execFileAsync('node', [path.join(outputDir, 'worker.js')], { cwd: outputDir });
const { stdout } = await execFileAsync(process.execPath, [path.join(outputDir, 'worker.js')], { cwd: outputDir });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 55e6a9a — switched to process.execPath.

Comment thread tools/egg-bundler/src/lib/PackRunner.ts Fixed
@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.57%. Comparing base (7b062db) to head (0396df0).
⚠️ Report is 3 commits behind head on next.

Additional details and impacted files
@@           Coverage Diff           @@
##             next    #5979   +/-   ##
=======================================
  Coverage   85.57%   85.57%           
=======================================
  Files         669      669           
  Lines       19849    19849           
  Branches     3923     3923           
=======================================
  Hits        16985    16985           
  Misses       2475     2475           
  Partials      389      389           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 20, 2026

Copy link
Copy Markdown

Deploying egg with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0396df0
Status: ✅  Deploy successful!
Preview URL: https://df68a1f2.egg-cci.pages.dev
Branch Preview URL: https://fix-bundler-class-field-shad.egg-cci.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 20, 2026

Copy link
Copy Markdown

Deploying egg-v3 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0396df0
Status: ✅  Deploy successful!
Preview URL: https://d64a73b2.egg-v3.pages.dev
Branch Preview URL: https://fix-bundler-class-field-shad.egg-v3.pages.dev

View logs

Comment thread tools/egg-bundler/src/lib/PackRunner.ts Dismissed
Declared-but-uninitialized TypeScript class fields (e.g. `createdAt: Date;` on a
leoric `Bone` model) compiled with `useDefineForClassFields: true` become native
own `undefined` properties at runtime. They shadow the get/set accessors ORMs
install on the prototype for decorated attributes, so writes never reach the
setter and the column is silently dropped on INSERT (observed as missing
`gmt_create`).

The bundle must compile these with `useDefineForClassFields: false` (matching how
Egg apps build with target <= ES2021) so the bare declarations are erased and the
prototype accessors stay effective.

Placing that flag is subtle: @utoo/pack (Turbopack) resolves the governing
tsconfig from the PROJECT dir (the `projectPath` passed to `build()`), NOT the
output dir and NOT via per-file find-up to the nearest tsconfig. Verified against
@utoo/pack 1.4.13/1.4.14 with an isolated repro: a tsconfig in the output dir is
ignored, and one nearer the source than projectPath is ignored — only
`projectPath/tsconfig.json` wins. (See utooland/utoo#2450, confirmed and closed
COMPLETED: the option works; only placement matters.) Bundler passed
`projectPath = app baseDir`, whose own tsconfig extends @eggjs/tsconfig
(target ES2024 → useDefineForClassFields defaults to true), so the field survived.

Fix:
- PackRunner writes the compiler tsconfig (experimentalDecorators,
  emitDecoratorMetadata, target, useDefineForClassFields:false) into the PROJECT
  dir instead of the output dir. It refuses to overwrite an existing, differing
  tsconfig.json when projectPath is not a build-managed (.egg-bundle) directory,
  so a user's own tsconfig is never silently clobbered.
- Bundler passes `projectPath = the generated entry dir` (build-managed
  `.egg-bundle/entries`) and `rootPath = app baseDir` (a caller-supplied rootPath
  is resolved against baseDir), so the compiler tsconfig is the one @utoo/pack
  resolves; the app's own tsconfig is neither read nor overwritten, and app
  sources + node_modules above the entry dir still resolve.

Verified with a real @utoo/pack build (decorator-free leoric-like model, so no
@swc/helpers needed): the bare field is erased, the prototype setter side effect
survives (`toSQLValues()` keeps the column), decorator metadata (design:type) is
still emitted, external node_modules deps still resolve/inline, and the app's own
tsconfig is left untouched. A real-build regression test pins this so a future
@utoo/pack upgrade or a tsconfig-placement regression is caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@killagu killagu force-pushed the fix/bundler-class-field-shadowing branch from 55e6a9a to 0396df0 Compare June 20, 2026 14:33
@killagu killagu merged commit 6c1ea15 into next Jun 20, 2026
23 of 25 checks passed
@killagu killagu deleted the fix/bundler-class-field-shadowing branch June 20, 2026 14:54
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.

3 participants