Skip to content

Defer webpack/rspack package-index side effects - #1107

Merged
justin808 merged 39 commits into
mainfrom
codex/fix-require-shakapacker-side-effects
Jun 30, 2026
Merged

Defer webpack/rspack package-index side effects#1107
justin808 merged 39 commits into
mainfrom
codex/fix-require-shakapacker-side-effects

Conversation

@justin808

@justin808 justin808 commented Apr 30, 2026

Copy link
Copy Markdown
Member

Summary

  • Defer webpack and rspack baseConfig / rules loading behind lazy, configurable exports so require("shakapacker") and require("shakapacker/rspack") do not initialize plugin, rules, or manifest code at package-index load time.
  • Preserve explicit config-generation behavior: generateWebpackConfig() and generateRspackConfig() still load environment/base config when config generation is requested.
  • Keep the lazy exports overridable: assigning to baseConfig / rules writes the value the getter returns (assigning undefined resets to lazy loading), and Object.defineProperty(..., { value, writable: true, configurable: true }) is also supported. The TypeScript declarations type them as plain (non-readonly) properties to match.
  • Keep rspack's native ESM named-import compatibility for eager/static CommonJS exports such as generateRspackConfig, while exposing lazy accessor values through the default/CommonJS namespace.
  • Add regression coverage for webpack and rspack package-index side effects, lazy export descriptors, override migration behavior, lazy export shape, rspack declaration/native ESM import output, and the dummy rspack server-bundle manifest handling.
  • Update spec/dummy/Gemfile.lock from 10.1.0.rc.1 to 10.1.0 so Bundler frozen mode matches the released gemspec version.

Fixes #1095.

Review / Discussion Notes

  • Must-fix feedback has been addressed in the current diff: webpack and rspack lazy baseConfig / rules exports defer their side effects, remain overridable by direct assignment (a stray undefined resets to lazy loading rather than being cached), and both entry points cover baseConfig and rules. (An earlier iteration made these readonly with throwing setters; that was reverted — assignment now succeeds and the declarations are non-readonly.)
  • The webpack rules shape suggestion is covered in test/package/indexSideEffects.test.js by lazily exposes rules with the expected shape.
  • The Object.defineProperty migration path is covered for both baseConfig and rules in test/package/indexSideEffects.test.js.
  • The rspack missing type-assertion comments are addressed in package/rspack/index.ts; the relevant require() calls cast to RuleSetRule[] and RspackConfigWithDevServer.
  • The rspack TypeScript CommonJS emit dependency is documented in package/rspack/index.ts, and test/package/rspack/indexTypes.test.js verifies the compiled entry still supports the intended ESM interop paths.
  • Discussion: baseConfig and rules are dynamic CommonJS accessor descriptors. Node's native ESM named-import detection only works for statically detected CJS exports, so ESM consumers should use the default import namespace for these lazy values: import rspack from "shakapacker/rspack"; const { baseConfig, rules } = rspack. My advice is to keep this pattern until the broader module/export strategy is handled under Replace export = #641; switching rspack to export = rspackExports would simplify the source, but would risk the native named-import compatibility currently protected for eager exports.

CI Notes

  • Previous failing check: Test with RSpack.
  • Root cause: the dummy rspack server-bundle config filtered RspackManifestPlugin, but rspack-manifest-plugin instances use constructor name WebpackManifestPlugin. The server-only config kept a manifest writer and overwrote the client manifest, leaving only server-bundle.js.
  • Fix: strip WebpackManifestPlugin from the dummy server-bundle config and add a Jest regression proving the server config does not write over the client manifest.
  • Latest failing checks were ESLint-related: max-classes-per-file in test/spec/dummy/rspackConfig.test.js and jest/expect-expect in test/package/rspack/indexTypes.test.js.
  • Fix: replace dummy plugin classes with named constructor functions, assert the native ESM subprocess output, and isolate the webpack side-effects shape test from the prior plugin mock.

Validation

  • git rebase origin/main
  • yarn --ignore-engines test --runInBand test/package/rspack/indexTypes.test.js test/spec/dummy/rspackConfig.test.js test/package/rspack/indexSideEffects.test.js test/package/indexSideEffects.test.js
  • yarn --ignore-engines eslint package/rspack/index.ts test/package/rspack/indexTypes.test.js test/spec/dummy/rspackConfig.test.js test/package/rspack/indexSideEffects.test.js test/package/indexSideEffects.test.js --max-warnings 0
  • yarn --ignore-engines eslint . --max-warnings 5
  • yarn --ignore-engines test --runInBand
  • yarn --ignore-engines type-check
  • git diff --check

Note

Medium Risk
Public package entry behavior and native ESM import paths change (documented breaking); core config generation paths are heavily tested but touch all consumers of shakapacker / shakapacker/rspack.

Overview
Defers plugin, rules, and manifest initialization until baseConfig / rules are read or config is generated, so plain require("shakapacker") and require("shakapacker/rspack") no longer run that work at load time (fixes #1095). Both entry points use a new createLazyExport helper: memoized getters, overridable via assignment, with generateWebpackConfig / generateRspackConfig unchanged for normal env files.

Breaking: lazy accessor exports are not Node static CJS named exports. Native ESM must use default import and destructure baseConfig / rules; on the webpack entry, named imports like import { config } now fail at load. CommonJS and generate* exports are unchanged.

Also strips server-bundle manifest plugins by constructor name ManifestPlugin suffix (rspack-manifest-plugin reports as WebpackManifestPlugin), with regression tests for dummy rspack multi-compiler setup and compiled ESM interop.

Reviewed by Cursor Bugbot for commit 6f8291d. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Breaking Changes

    • Key exports (baseConfig, rules) are now lazy getters; native ESM named imports of those can fail at module load — use default import + destructure. CommonJS access/assignment still works.
  • Bug Fixes

    • Requiring the package no longer triggers plugin/manifest initialization until config generation/first access.
    • Server bundling now filters out WebpackManifestPlugin from server config.
  • Tests

    • Added/expanded tests for lazy-export behavior, memoization, native ESM failure cases, and rspack interop.
  • Documentation

    • Changelog and type docs updated to describe lazy loading and possible runtime throws.

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@justin808, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4032c240-006d-440c-8110-7d5fd3f3efd4

📥 Commits

Reviewing files that changed from the base of the PR and between c67121d and 5ac330f.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • package/index.d.ts
  • package/index.d.ts.template
  • package/index.ts
  • package/rspack/index.ts
  • package/utils/createLazyExport.ts
  • spec/dummy/config/rspack/rspack.config.js
  • test/package/createLazyExport.test.js
  • test/package/indexSideEffects.test.js
  • test/package/indexTypes.test.js
  • test/package/rspack/indexSideEffects.test.js
  • test/spec/dummy/rspackConfig.test.js
  • tsconfig.json
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-require-shakapacker-side-effects

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.

@claude

claude Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overall: The approach is correct and well-scoped. Deferring the environments/base require to a lazy getter cleanly severs the eager side-effect chain (getPlugins()ensureManifestExists) without removing baseConfig from the public API. The manually-maintained index.d.ts already declares baseConfig: Configuration, so TypeScript consumers are unaffected.

A few things worth addressing before merging:

Bug risk – Object.defineProperty defaults configurable: false

Previously baseConfig was a plain data property, which is reconfigurable. With configurable: false (the default), any code that tries to reassign or redefine the property after the fact will throw in strict mode. The main practical risk is in test environments: jest.spyOn, Object.assign, or a second Object.defineProperty on the same key will throw. Consider adding configurable: true unless the intent is to make the property immutable.

Test coverage gap – no positive assertion that baseConfig is still accessible

The new test only verifies the negative case (no side effects on require). There is no test confirming that shakapacker.baseConfig still resolves to an object with the expected shape after this refactor. The rspack index test suite has this at lines 109–111 (test("exports baseConfig object", ...)), but the webpack index test suite (test/package/index.test.js) is missing an equivalent. A one-line assertion in the existing index test would close this gap.

Minor – afterEach(() => jest.resetModules()) is redundant

jest.isolateModules already creates a fresh module registry for its callback scope. The outer afterEach(jest.resetModules) does nothing additional for a suite that wraps every require inside isolateModules. It's harmless but creates a false impression that it's doing cleanup work.

Comment thread package/index.ts Outdated
Comment thread test/package/indexSideEffects.test.js
Comment thread test/package/indexSideEffects.test.js
@claude

claude Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up Review

This is a follow-up to my earlier review. The three issues I raised have been addressed in the latest commit:

  • Object.defineProperty now explicitly passes configurable: true
  • A positive shape assertion (lazily exposes baseConfig with the expected shape) was added to the test suite ✓
  • The afterEach cleanup is now jest.clearAllMocks() instead of the misleading jest.resetModules()

Remaining nit

afterEach(() => { jest.clearAllMocks() }) wraps the call in an unnecessary arrow function — afterEach(jest.clearAllMocks) is equivalent and cleaner. Not a blocker.

Overall assessment

The implementation is correct:

  • environments/base.ts uses export = baseConfig (CommonJS-compatible), so require("./environments/base") returns the config object directly — no .default unwrap needed.
  • Node's module cache ensures repeated baseConfig accesses are cheap (first access loads, subsequent hits return from cache).
  • The enumerable: true flag keeps baseConfig visible in Object.keys() and spreads, preserving backward-compatible introspection.
  • index.d.ts already declares baseConfig: Configuration, so TypeScript consumers are unaffected.
  • The test file ends with a trailing newline ✓

Ready to merge once the draft status is removed.

Comment thread package/index.ts Outdated
Comment thread test/package/indexSideEffects.test.js
@claude

claude Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overall: A well-targeted fix for issue #1095. The lazy-getter approach is correct and the regression tests are a good addition. Two gaps worth addressing before merging.


Critical: package/rspack/index.ts has the same problem and is not fixed

package/rspack/index.ts:13 still eagerly loads environments/base at module scope:

const baseConfig = require("../environments/base")  // line 13 — eager, same side-effect

and exports it directly (line 77). Rspack users hit the exact same unwanted side effects that this PR fixes for webpack users. The fix should be applied consistently to rspack/index.ts as well, otherwise the fix is incomplete for anyone using the rspack entry point.


Missing CHANGELOG entry

This is a user-visible bug fix (unexpected side effects on require("shakapacker")). Per the project guidelines it warrants a ### Fixed entry in the [Unreleased] section, e.g.:

Fixed require("shakapacker") triggering webpack plugin initialization as a side effect. PR #1107 by justin808. baseConfig is now exposed via a lazy getter so ensureManifestExists and plugin constructors are only invoked when config generation is actually requested. Fixes #1095.


Minor: getter not memoized (left as inline comment)

Each shakapacker.baseConfig access traverses the require cache. Memoizing on first call makes the one-time side-effect nature explicit. See inline comment.


Minor: test description doesn't match assertion (left as inline comment)

The first test says "does not initialize webpack plugins" but only asserts ensureManifestExists wasn't called. Worth tightening the wording to match what's actually verified.


What looks good

  • The lazy Object.defineProperty pattern is correct — configurable: true enables mocking in tests, enumerable: true keeps the property visible to spreads and Object.keys.
  • index.d.ts already declares baseConfig: Configuration so the public type contract is unchanged.
  • jest.isolateModules usage is correct for per-test module isolation.
  • The third test correctly verifies the eager-load guard (checks ensureManifestExists is not called before accessing baseConfig, then called exactly once after).
  • Trailing newline present in the new test file. ✓

Comment thread package/index.ts Outdated
Comment thread test/package/indexSideEffects.test.js
Comment thread package/index.ts Outdated
@claude

claude Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This PR fixes #1095 by removing the eager import baseConfig from "./environments/base" in the package index. Because environments/base loads plugin constructors and calls ensureManifestExists at require-time, simply importing the index was triggering file-system checks and plugin initialization. The fix introduces a lazy, cached getter so those side effects are deferred until baseConfig (or generateWebpackConfig) is actually used. The approach is correct and the regression test is well-targeted.


Must-Fix

1. index.d.tsbaseConfig should be readonly

The runtime property now has a getter but no setter. TypeScript callers who write shakapacker.baseConfig = myConfig will get no compile-time error but a runtime TypeError in strict mode. The declaration should be updated (even though the file isn't part of this diff):

// package/index.d.ts, line 29
readonly baseConfig: Configuration

2. Missing setter / breaking-change documentation

See the inline thread on Object.defineProperty. If any consumer previously did shakapacker.baseConfig = customConfig, that now silently fails in sloppy mode or throws in strict mode. Even if intentional, this should be called out in the CHANGELOG entry for the release that ships this fix.


Should-Fix

3. Inconsistency between first and third test

mockWebpackPlugins() is defined at the top of the describe block and called in the first test, but deliberately omitted from the third test ("lazily exposes baseConfig with the expected shape"). That third test lets the real getPlugins() run, which means it's an integration test that depends on webpack, webpack-assets-manifest, etc. being installed. That's fine in CI, but the asymmetry is surprising and the intent should be documented with a brief comment so future contributors don't assume it was accidentally omitted.


Nits / Suggestions

  • The module-level let baseConfig cache variable shares its name with the exported property, making the Object.defineProperty block slightly confusing to read. Renaming it to _baseConfigCache or _cachedBaseConfig would reduce the cognitive load (see inline thread).
  • The third test hardcodes mode: "production". environments/base.ts hardcodes that value too, so it's currently correct — just something to keep in mind if base.ts ever becomes environment-aware.

What's Working Well

  • The lazy-getter pattern is the right tool here — it preserves the public API surface while eliminating load-time side effects.
  • configurable: true is a good choice; it keeps the property replaceable via Object.defineProperty for consumers who need to override it in tests.
  • jest.isolateModules + jest.doMock is used correctly — each test gets a fresh module registry with fresh mocks, avoiding cross-test pollution.
  • The generateWebpackConfig fallback to getBaseConfig() is handled cleanly.

@justin808

Copy link
Copy Markdown
Member Author

Review response (commit 54bc327)

Addressed the follow-up review (issue comments #4412036948 and #4412144693):

  • package/rspack/index.ts — same lazy-load fix applied. environments/base, plugins/rspack, and optimization/rspack are now deferred. require("shakapacker/rspack") no longer eagerly resolves @rspack/core or rspack-manifest-plugin via plugin/optimization modules. baseConfig is exposed via Object.defineProperty(module.exports, "baseConfig", { get }) so the ESM named-export shape is preserved for existing consumers.
  • CHANGELOG entry added under [Unreleased]### Fixed, covering both webpack and rspack and noting the read-only baseConfig behavior change.
  • baseConfig marked readonly in package/index.d.ts.template (the source for the generated index.d.ts). Did not add a setter — kept the API surface minimal per the project's open-source maintainability rules; users who need to override can use Object.defineProperty.
  • Inline nits addressed: renamed module-level cache variable to _baseConfig; added a comment in the third side-effects test explaining why mockWebpackPlugins() is deliberately omitted (so real plugin constructors run as a smoke check).
  • Regression test added for the rspack baseConfig lazy-getter descriptor in test/package/rspack/index.test.js.

The three follow-up items confirmed addressed in #4411942887 (configurable: true, positive shape assertion, afterEach cleanup) were already landed in earlier commits — the corresponding inline threads have been replied-to and will be resolved.

Comment thread package/rspack/index.ts
@claude

claude Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #1107: Avoid package index plugin side effects

Overall: The goal is solid and the fix is well-motivated. Preventing eager side-effectful imports when merely require-ing the package index is a real problem (#1095) and the lazy-getter approach is a reasonable solution. A few issues worth addressing before merging.


Bug: Dead code in generateRspackConfig (rspack/index.ts)

getPlugins and getOptimization are required inside generateRspackConfig but the destructured variables are never called:

const { getPlugins } = require("../plugins/rspack")       // loaded but unused
const { getOptimization } = require("../optimization/rspack")  // loaded but unused

return webpackMerge.merge({}, environmentConfig, extraConfig)  // neither used here

Worse, they are also redundant: by the time these lines run, both modules are already loaded transitively — environmentConfig is set from either require(path) (the environment file, which requires ./base, which requires the bundler plugins) or getBaseConfig() (same chain). The side-effect the comment refers to has already happened before these lines execute.

Options:

  • Remove them entirely — the side effects are already triggered by environmentConfig.
  • Or, if the intent is purely side-effectful loading with an explicit comment, use bare require(...) without destructuring. But given they're already loaded, removal is cleaner.

This will also avoid ESLint no-unused-vars warnings.


Missing rspack side-effects regression test

test/package/indexSideEffects.test.js covers webpack. There is no parallel test confirming that require("shakapacker/rspack") does not eagerly load @rspack/core or rspack-manifest-plugin. The new descriptor test in test/package/rspack/index.test.js only verifies the getter shape, not the absence of eager loading. Given that the rspack side was the primary motivation (per #1095), a mirror of the webpack side-effects test for rspack would be valuable.


Minor: Misleading inline comment

The comment inside generateRspackConfig:

// Lazy-load plugin/optimization modules so simply requiring this index does
// not trigger @rspack/core or rspack-manifest-plugin resolution.

…describes a module-load-time concern, but this code runs inside a function that is called at runtime, well after the index has been required. The comment belongs (if anywhere) near the module-level removal of the top-level requires in the diff, not inside the function body.


Minor: Breaking change visibility

The readonly baseConfig / no-setter behavior is documented in the CHANGELOG and the JSDoc, which is good. One consideration: the configurable: true setting means consumers can work around it with Object.defineProperty, but a consumer doing const sp = require("shakapacker"); sp.baseConfig = myConfig in sloppy mode will silently do nothing. A set() trap that throws with a clear message (instead of silent failure) would give a much better DX than relying on strict mode, e.g.:

Object.defineProperty(shakapacker, "baseConfig", {
  configurable: true,
  enumerable: true,
  get: getBaseConfig,
  set() {
    throw new TypeError(
      "shakapacker.baseConfig is read-only. Use Object.defineProperty to override."
    )
  }
})

Not a blocker, but worth considering.


Positive notes

  • The jest.isolateModules approach in tests is correct — it ensures a fresh module instance (and fresh _baseConfig = undefined) per test, avoiding state bleed between test cases.
  • environments/base.ts uses export = (CommonJS-style) so require("./environments/base") in the new lazy getter correctly returns the config object directly (not { default: config }). No interop issue.
  • Marking baseConfig as readonly in the TypeScript declarations is the right call.

Comment thread package/rspack/index.ts Outdated
@justin808
justin808 force-pushed the codex/fix-require-shakapacker-side-effects branch from 54bc327 to 74d9cb1 Compare May 12, 2026 04:42
Comment thread package/rspack/index.ts Outdated
Comment thread test/package/indexSideEffects.test.js
Comment thread package/index.ts Outdated
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Review of PR #1107 — Avoid package index plugin side effects

Overall: The goal is well-motivated and the approach (lazy getter via Object.defineProperty) is sound for the webpack (package/index.ts) path. The changelog entry, TypeScript type update, and new regression test are all appropriate. However there is one concrete bug in the rspack path and a gap in test coverage that should be addressed before merging.


Bug — dead code in package/rspack/index.ts (blocking)

Inside generateRspackConfig, two modules are required but their results are never used:

const { getPlugins } = require("../plugins/rspack")
const { getOptimization } = require("../optimization/rspack")

return webpackMerge.merge({}, environmentConfig, extraConfig)  // getPlugins/getOptimization ignored

This is a double problem:

  • The lazy-load goal is not met: @rspack/core and rspack-manifest-plugin are still loaded on every generateRspackConfig() call, just moved from module-load time to call time.
  • The results are discarded: getPlugins() and getOptimization() are never called at all, so if the environment config doesn't already include them (e.g. a custom env that extends base differently) they will be silently absent.

In practice both are already applied transitively — environments/base.ts calls getPlugins() at its own module scope, and environments/production.ts calls getOptimization() — so this doesn't cause a visible regression in the standard paths. But the unused require calls should still be removed; they are misleading and negate the stated lazy-loading benefit for the rspack entrypoint. See the inline comment for the suggested fix.


Missing regression tests for rspack side effects

test/package/indexSideEffects.test.js only exercises package/index (the webpack path). The rspack entrypoint (package/rspack/index) has the same lazy-getter contract but no equivalent coverage. Given that the rspack path has the bug above, this gap is meaningful. See the inline comment on the test file for what to add.


Minor

  • The as Configuration cast in the webpack getBaseConfig return statement is redundant (TypeScript already knows the type from the function signature and the variable declaration). Addressed in an inline comment.

Comment thread package/rspack/index.ts Outdated
@justin808

Copy link
Copy Markdown
Member Author

@claude[bot] all four review summary comments (#4351248577, #4411942887, #4412036948, #4412144693, #4417236313, #4427414791) have been triaged:

  • Inline items are addressed in commit 63d8c8d with replies on each thread:

    • Dead getPlugins / getOptimization requires in generateRspackConfig → removed.
    • Missing rspack side-effects test → test/package/rspack/indexSideEffects.test.js added.
    • Redundant as Configuration cast → replaced with a non-null assertion (full cast removal fails type-check on the captured let; ! is cleaner than as and equivalently safe).
  • afterEach nit (#4411942887): moot — test/package/indexSideEffects.test.js has no afterEach block (it was removed entirely in an earlier commit since jest.isolateModules already provides isolation).

  • Setter trap for baseConfig (#4417236313): declined. The property is intentionally read-only per the open-source maintainability rule ("prefer removing complexity over adding configuration"). Strict-mode consumers will see the TypeError; sloppy-mode silent failure is an acceptable trade-off vs. shipping a setter trap, and Object.defineProperty remains available for the rare consumer who needs to override.

  • @rspack/core rules-chain eager-load (noted in the new rspack test reply): tracked but out of scope for this PR; the new test confirms the lazy-getter promise that this PR introduces for rspack-manifest-plugin.

Thanks for the thorough reviews.

@justin808

Copy link
Copy Markdown
Member Author

Review pass — commit 63d8c8d

Addressed all open review items:

Item Status Where
Dead getPlugins / getOptimization requires in generateRspackConfig (3 reviewers) Fixed package/rspack/index.ts — destructures + rationale comment removed
Missing rspack side-effects regression test Added test/package/rspack/indexSideEffects.test.js (3 tests covering lazy-load, descriptor shape, and lazy-trigger)
Redundant as Configuration cast Fixed (with tweak) Replaced with return _baseConfig! in both package/index.ts and package/rspack/index.ts — dropping the cast outright fails type-check because TS won't narrow a captured let across the if-assignment
afterEach nit Already resolved test/package/indexSideEffects.test.js has no afterEach
set trap for baseConfig Declined Read-only is intentional per open-source maintainability rules; consumers can override via Object.defineProperty

Verification

  • yarn type-check — clean
  • yarn lint on changed files — clean
  • yarn test --runInBand — 478/478 passing (52 suites)

Resolved threads

  • #discussion_r3216130856 (dead code)
  • #discussion_r3223759664 (dead code, dup)
  • #discussion_r3223762583 (dead code, cursor)
  • #discussion_r3223760132 (missing rspack test)
  • #discussion_r3223760405 (as cast)

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overall: Clean, well-motivated fix for #1095. The lazy-getter approach is the right tool here, tests are comprehensive, and the CHANGELOG/type declaration updates are correct.

Issues worth addressing

1. module.exports style in rspack index vs named-object style in webpack index

package/rspack/index.ts uses Object.defineProperty(module.exports, "baseConfig", ...) while package/index.ts uses Object.defineProperty(shakapacker, "baseConfig", ...) (a named local variable). Both are functionally equivalent here (TypeScript compiles named export {} to property assignments on the same exports/module.exports object), but the rspack version is less readable and harder to reason about. Consider collecting the named exports into a local object and using export = to match the webpack index pattern, or at minimum use a const alias (const rspackExports = module.exports) to make the target explicit.

2. Silent failure on baseConfig assignment

The property descriptor has no set, so shakapacker.baseConfig = custom silently no-ops in sloppy mode and throws a bare TypeError in strict mode. Both are documented, but a custom setter that throws a descriptive error would be more developer-friendly:

set(_v) {
  throw new TypeError(
    'shakapacker.baseConfig is read-only. Use Object.defineProperty to override it.'
  )
}

3. Minor: test side-effect access

test/package/rspack/indexSideEffects.test.js line 86 accesses rspackIndex.baseConfig as a bare statement expression (suppressed with // eslint-disable-line no-unused-expressions). Prefer void rspackIndex.baseConfig or expect(rspackIndex.baseConfig).toBeDefined() to make the intent explicit without suppressing the lint rule.

Notes / no action needed

  • Double-caching: The module-level _baseConfig variable is technically redundant with Node's require cache in production but intentional — it guarantees correct behaviour under jest.isolateModules() where the require cache is reset. ✅
  • configurable: true: Correctly provided as the documented Object.defineProperty escape hatch. ✅
  • TypeScript readonly: The .d.ts / .d.ts.template change matches the runtime behaviour. ✅
  • Trailing newlines: All new/modified files end with \n. ✅

Comment thread package/rspack/index.ts Outdated
Comment thread test/package/rspack/indexSideEffects.test.js Outdated
Comment thread package/index.ts Outdated
justin808 and others added 17 commits June 29, 2026 23:58
Fixes the must-fix isolation flaw plus six optional hardening/coverage
items from PR #1107 review:

- test/spec/dummy/rspackConfig.test.js: clear WEBPACK_SERVE in loadConfig so
  configFactory() always returns the [client, server] array the spec indexes,
  matching rspack.config.js's WEBPACK_SERVE short-circuit and the sibling
  CLIENT_BUNDLE_ONLY/SERVER_BUNDLE_ONLY deletes.
- package/rspack/index.ts: document why RuleSetRule is imported from "webpack"
  (single shared rule set consumed by both bundlers).
- test/package/indexSideEffects.test.js: add a symmetric "memoizes rules" test.
- test/package/rspack/indexTypes.test.js: inherit stdout for the tsc compiles so
  a compile failure surfaces readable diagnostics instead of a raw Buffer.
- package/index.ts, package/rspack/index.ts: widen lazy-export setters to accept
  undefined (the documented lazy-reset path).
- package/index.ts: add the load-time lazy-getter guard for parity with the
  rspack entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- package/index.ts, package/rspack/index.ts: point the lazy-getter
  install guard at the published .js entry instead of the .ts source,
  which npm consumers never receive.
- package/index.ts: document that only direct `baseConfig` assignment
  runs the setter; a value-descriptor `Object.defineProperty` override
  bypasses it and won't propagate to `generateWebpackConfig`.
- test/package/indexSideEffects.test.js: clarify that the
  defineProperty override test only proves the export stays redefinable,
  not that it reaches config generation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…641)

- Surface the Object.defineProperty({value}) override caveat in the public
  baseConfig JSDoc (index.d.ts and its template) so it shows up in consumers'
  editors, not just the setter source comment.
- Add a TODO(#641) marker to the rspack entry's explanatory comment block,
  tying the reliance on TypeScript's CommonJS emit to the deferred #641 work.

Comment/JSDoc-only changes; no runtime or type-shape changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… tsconfig note, test teeth

- Correct the baseConfig override documentation (codex P2): direct assignment
  overrides the read-back value but only feeds generateWebpackConfig/
  generateRspackConfig in the fallback where no environments/<NODE_ENV>.js
  exists. Normal NODE_ENV builds load environments/<env>.js (which require the
  real base directly), so the override does not affect them. Updated the webpack
  and rspack setter comments and the baseConfig JSDoc (index.d.ts + template).
- Clarify the webpack lazy-getter guard is kept for parity/documentation, not
  runtime protection (defineProperty on a plain object throws synchronously).
- Note in tsconfig.json why module: "commonjs" is required for the rspack
  lazy named-export mechanism (TODO(#641)).
- Give the rspack "does not eagerly load" test teeth: assert the initial
  require makes zero requireOrError calls, so a newly-added eager dependency
  can't slip past the mock's empty-object fallthrough.

Docs/comment/test-only; no runtime behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…override caveat

- test/package/indexTypes.test.js: inherit tsc stdout like the rspack
  analogue so compile failures surface readable diagnostics instead of
  a raw Buffer in the thrown error
- package/rspack/index.ts: point the lazy-getter mechanism comment at
  tsconfig.json so the module:commonjs dependency is discoverable from
  either file
- CHANGELOG.md: note in the #1107 Fixed entry that direct baseConfig
  assignment only affects generate*Config output in the fallback case
  with no environments/<NODE_ENV>.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The getters are installed on a freshly-created plain object, so the
Object.defineProperty calls throw synchronously if they ever fail and
the guard could never fire. The rspack entry keeps its guard, where
getters installed on the CommonJS exports object are real protection.
The getter contract is locked by test/package/indexSideEffects.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidate the duplicated lazy getter/setter pattern for `rules` and
`baseConfig` in the webpack and rspack entry points into a single
`createLazyExport` helper. Both entries now install accessor descriptors
from the helper instead of hand-rolled getters, setters, and load flags.

Override semantics (lazy first-get caching, assignment override,
undefined reset, and defineProperty value-descriptor bypass) are
preserved and documented on the helper. Match rspack manifest plugins by
constructor-name suffix in the dummy app config. Update and consolidate
the side-effect and type tests accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Track computation with a dedicated `loaded` flag instead of an
  `=== undefined` sentinel, so a `load` that legitimately returns
  `undefined` is cached once rather than silently re-running its side
  effects on every access.
- Tighten the returned descriptor to a literal type so the
  configurable/enumerable/accessor invariants are enforced at the call
  sites instead of being erased to the loose built-in PropertyDescriptor.
- Add createLazyExport unit tests covering lazy load, memoization,
  undefined caching, descriptor shape, assignment override/reset, and
  value-descriptor bypass.
- Add webpack + rspack baseConfig override tests covering both the
  no-environment-file fallback (override flows in) and the normal
  NODE_ENV path (override intentionally ignored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spec/dummy rspack server config strips manifest plugins by
constructor-name suffix. Note in the comment that the match is
intentionally broad: any plugin whose constructor name ends in
`ManifestPlugin` is removed from the server config, not just the
known RspackManifestPlugin/WebpackManifestPlugin aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assigning `undefined` to a lazy export previously re-armed lazy loading
instead of caching the assigned value. That inverted standard property
semantics and created a footgun (`x = custom || undefined` silently
reset to the loader). Since the lazy value type `T` is always a
non-nullable object in practice (Configuration, RspackConfigWithDevServer,
RuleSetRule[]) and nothing depended on the reset behavior, the setter now
caches whatever is assigned — including `undefined` — like any other
property.

- Narrow the descriptor setter type from `T | undefined` to `T`.
- Update the createLazyExport JSDoc and the webpack/rspack entry-point
  override-semantics comments.
- Flip the five reset tests to assert assignment caches the value as-is
  without re-running the loader.

This behavior is unreleased (PR #1107), so there is no breaking change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two small review nits from the latest re-review:

- createLazyExport: note in the JSDoc that a throwing `load` leaves
  `loaded` false, so the next `get` retries — a side-effectful loader
  that keeps failing can run more than once. Makes the retry behavior an
  explicit contract rather than an implementation detail.
- indexTypes.test.js: guard the `lib/` symlink with an existsSync check
  so a checkout missing `lib/` fails with a clear message instead of a
  bare ENOENT from symlinkSync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
justin808 added a commit that referenced this pull request Jun 30, 2026
The "with custom env prefix" test required package/config before setting
NODE_ENV/RAILS_ENV. config resolves dev_server from shakapacker.yml at load
time, and the production environment has no dev_server section. Under a
reordered run (jest --randomize) where the production test executed first,
NODE_ENV=production leaked into this test, config.dev_server was undefined,
and the test crashed with "Cannot set properties of undefined (setting
'env_prefix')".

Set the development env vars before requiring config so the test is
independent of execution order. Surfaced while running the suite under
--randomize during review of #1107.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@justin808
justin808 force-pushed the codex/fix-require-shakapacker-side-effects branch from 97e8603 to 5ac330f Compare June 30, 2026 09:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ac330ff9e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread package/rspack/index.ts
// single shared rule set (../rules/rspack.js) consumed by both bundlers, and
// rspack's rule shape is compatible with webpack's. Using the webpack type keeps
// the `rules` export type identical across the webpack and rspack entry points.
import type { RuleSetRule } from "webpack"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid requiring webpack types from the rspack entry

This type-only import is emitted into package/rspack/index.d.ts as import type { RuleSetRule } from "webpack", so an rspack-only TypeScript app fails declaration resolution even when it only imports generateRspackConfig. The rspack installer preset only installs rspack packages (lib/install/package.json lines 2-7), not webpack or @types/webpack, so this makes fresh rspack TS configs require an unrelated webpack type package; use an rspack/local structural rule type or avoid exposing a webpack-typed rules declaration here.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Summary

This PR correctly fixes #1095 by introducing createLazyExport to defer baseConfig / rules initialization until first access. The lazy-getter mechanism, retry-on-throw behavior, memoization, and setter semantics are all implemented and tested correctly. The plugin-name filter fix for WebpackManifestPlugin in the rspack server config is also sound.

Three findings surfaced that are worth addressing before merge:


1. PR description contradicts implementation — setter caches undefined, does not re-arm lazy loading

The Review / Discussion Notes section states:

remain overridable by direct assignment (a stray undefined resets to lazy loading rather than being cached)

But the actual setter is:

set(value: T) {
  cached = value   // permanently caches whatever is passed, including undefined
  loaded = true    // never reset to false — lazy loading is NOT re-armed
}

createLazyExport.ts line 11 explicitly says "it never re-arms lazy loading", and the tests for both webpack and rspack assert that shakapacker.baseConfig = undefined returns undefined on the next read (not the lazy-loaded value). The description is backwards. This matters for reviewers reasoning about the API contract and for users who might try to "reset" an override by assigning undefined.


2. Three copies of the export-name list must be kept in sync (silent breakage if they drift)

The non-lazy webpack exports exist in three separate places:

  1. The shakapacker object literal (package/index.ts)
  2. The exports.xxx static-analysis block (lines 113–131 of package/index.ts)
  3. The staticExportNames array in test/package/indexTypes.test.js (lines 76–96)

A new export added to (1) but missed in (2) silently breaks import { newHelper } from 'shakapacker' for native-ESM consumers — require() still works because module.exports = shakapacker captures everything. The regex-based cross-check in indexTypes.test.js (line 106–108) guards (1)↔(2), but only runs after a full tsc compile, and the regex itself can throw rather than fail gracefully (see inline comment).


3. Missing rspack rules-memoization test

test/package/rspack/indexSideEffects.test.js has no test verifying that repeated reads of rspack.rules return the same cached reference (i.e., the rules module is not re-required on each access). The webpack side has this at test/package/indexSideEffects.test.js line 772. Worth adding for parity, especially given that the rspack rules path goes through the same createLazyExport closure.

)
const shakapackerObject = source.match(
/const shakapacker = \{([\s\S]*?)\n\}/
)[1]

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.

Unguarded null dereference: if the regex doesn't match (e.g. the variable is renamed or the closing } loses its leading newline), source.match(...) returns null and [1] throws a TypeError — an opaque crash rather than a useful assertion failure.

Suggested change
)[1]
const shakapackerObject = source.match(
/const shakapacker = \{([\s\S]*?)\n\}/
)?.[1]
if (!shakapackerObject) {
throw new Error(
"Could not locate `const shakapacker = { ... }` in package/index.ts — was it renamed or reformatted?"
)
}

Comment thread package/index.ts
Comment on lines +113 to +131
exports.config = config
exports.devServer = devServer
exports.generateWebpackConfig = generateWebpackConfig
exports.env = env
exports.moduleExists = moduleExists
exports.canProcess = canProcess
exports.inliningCss = inliningCss
exports.isRspack = isRspack
exports.isWebpack = isWebpack
exports.getBundler = getBundler
exports.getCssExtractPlugin = getCssExtractPlugin
exports.getCssExtractPluginLoader = getCssExtractPluginLoader
exports.getDefinePlugin = getDefinePlugin
exports.getEnvironmentPlugin = getEnvironmentPlugin
exports.getProvidePlugin = getProvidePlugin
exports.merge = webpackMerge.merge
exports.mergeWithCustomize = webpackMerge.mergeWithCustomize
exports.mergeWithRules = webpackMerge.mergeWithRules
exports.unique = webpackMerge.unique

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.

This 19-line block must manually stay in sync with the shakapacker object above. A new export added to the object but omitted here silently breaks import { newHelper } from 'shakapacker' for native-ESM consumers — require() still works because module.exports = shakapacker captures it, so the regression is invisible to CJS tests.

The consistency is guarded by the regex cross-check in test/package/indexTypes.test.js, but that test runs only after a full tsc compile and requires the regex to match correctly. Consider adding a unit-test guard that reads this block at the source level (e.g. parse exports\.\w+ = lines and compare against the object's own property names) so the check doesn't depend on the compile step or the fragile object-literal regex.

@justin808
justin808 merged commit 8640b51 into main Jun 30, 2026
26 checks passed
@justin808
justin808 deleted the codex/fix-require-shakapacker-side-effects branch June 30, 2026 10:27
justin808 added a commit that referenced this pull request Jun 30, 2026
The "with custom env prefix" test required package/config before setting
NODE_ENV/RAILS_ENV. config resolves dev_server from shakapacker.yml at load
time, and the production environment has no dev_server section. Under a
reordered run (jest --randomize) where the production test executed first,
NODE_ENV=production leaked into this test, config.dev_server was undefined,
and the test crashed with "Cannot set properties of undefined (setting
'env_prefix')".

Set the development env vars before requiring config so the test is
independent of execution order. Surfaced while running the suite under
--randomize during review of #1107.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
justin808 added a commit that referenced this pull request Jun 30, 2026
## Summary

Fixes an order-dependent (flaky) test in
`test/package/dev_server.test.js` that fails intermittently under `jest
--randomize`.

## Root cause

The `"with custom env prefix"` test required `package/config` **before**
setting `NODE_ENV`/`RAILS_ENV`:

```js
const config = require("../../package/config")   // loads dev_server from shakapacker.yml under whatever env is current
config.dev_server.env_prefix = "TEST_SHAKAPACKER_DEV_SERVER"

process.env.NODE_ENV = "development"              // too late
```

`config` resolves `dev_server` from `shakapacker.yml` at load time, and
the `production` section has no `dev_server`. When `jest --randomize`
runs the later `"production"` test first, `NODE_ENV=production` leaks
into this test, so `config.dev_server` is `undefined` and line 26
throws:

```
TypeError: Cannot set properties of undefined (setting 'env_prefix')
```

This reproduces ~1 in 8 randomized runs of the file in isolation.

## Fix

Set the development env vars **before** requiring `config`, making the
test independent of execution order. No production code changes.

## Verification

- `yarn jest test/package/dev_server.test.js --randomize` — 20/20 runs
green (previously ~1/8 failed).
- `yarn eslint test/package/dev_server.test.js` — clean.

Surfaced while running the suite under `--randomize` during review of
#1107; kept separate to stay focused.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only reordering and comments; no runtime or config behavior
changes.
> 
> **Overview**
> Fixes an order-dependent flake in `test/package/dev_server.test.js`
for the **"with custom env prefix"** case.
> 
> The test now sets `NODE_ENV` and `RAILS_ENV` to `development` (and the
prefixed dev-server env vars) **before**
`require("../../package/config")`, because `config` reads `dev_server`
from `shakapacker.yml` at load time and production has no `dev_server`
block. Under `jest --randomize`, a prior production test could leave
`NODE_ENV=production`, leaving `config.dev_server` undefined and causing
`config.dev_server.env_prefix = ...` to throw.
> 
> Comments in the test document that behavior. **No production code
changes.**
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4045332. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
  * Improved coverage for development server environment handling.
* Strengthened test setup and cleanup to prevent environment settings
from leaking between runs.
* Updated the custom environment prefix test to align with how
configuration is loaded during module startup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
justin808 added a commit that referenced this pull request Jul 4, 2026
## Summary

Stamps the **`v10.2.0`** release section in `CHANGELOG.md` and adds the
user-visible entries that were still missing for PRs merged since
`v10.1.0`.

Header format matches the repo convention (`## [v10.2.0] - July 3,
2026`) and is parseable by `rakelib/release.rake`'s
`extract_changelog_section` (`## [v<npm-version>]`), so `bundle exec
rake release` / `sync_github_release` will pick up the notes
automatically.

### Changelog changes

- **Version header**: inserted `## [v10.2.0] - July 3, 2026` immediately
after `## [Unreleased]`; all accumulated entries now live under it, and
`## [Unreleased]` is empty.
- **Compare links**: `[unreleased]` now compares `v10.2.0...main`; added
`[v10.2.0]: …/compare/v10.1.0...v10.2.0`.

### New entries added (were missing)

| PR | Section | Note |
| --- | --- | --- |
| [#1187](#1187) | Added |
Babel 8 peer dependency support + preset option compatibility |
| [#1184](#1184) | Added |
Folded into the #695 AI-prompt entry (gates the React on Rails section
on app detection) |
| [#1142](#1142) | Fixed |
Rspack dev-server config no longer loads in static watch mode (fixes
#1137) |

### Already documented (carried into v10.2.0)

`#1180`, `#695`, `#1141`, `#1150`, `#1179`, `#1192`, `#1127`, `#1178`,
`#1161`, `#1147`.

### Reviewed and intentionally excluded (not user-visible)

Docs: `#1145`, `#1148`, `#1152`, `#1155`, `#1183`, `#1188`, `#1189`,
`#1193`.
CI: `#1151`, `#1168`, `#1171`.
Tests / fixtures: `#1128`, `#1154`, `#1167`, `#1186`.
Workflow / agent tooling: `#1153`, `#1176`, `#1182`.

(`#1107` is already documented under `## [v10.1.0]`.)

## Next step

After merge, run the repo's release task (no args) — it reads `v10.2.0`
from the changelog and creates the GitHub release from this section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation p2 Medium: enhancements, docs, quality improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

require 'shakapacker' creates output directory as a side effect

1 participant