Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1502b93
Detect stale Pro migration module specifiers
justin808 Apr 30, 2026
fa3c407
Add changelog for Pro doctor specifier scan
justin808 Apr 30, 2026
fec385e
Clarify Pro doctor base package reference warning
justin808 May 4, 2026
1460984
Merge remote-tracking branch 'origin/main' into codex/pr-3232-reviews
justin808 May 4, 2026
43aa8c2
Cover Vitest base package references
justin808 May 4, 2026
a27ad34
Cover stale mock helper references
justin808 May 5, 2026
04179fc
Merge remote-tracking branch 'origin/main' into codex/pr-3232-reviews
justin808 May 5, 2026
0b64a12
Polish base package reference checks
justin808 May 5, 2026
365c11b
Polish doctor reference scanner review feedback
justin808 May 5, 2026
fe9e0bb
Cover additional Pro package reference cases
justin808 May 5, 2026
97c4fb8
Polish doctor reference review nits
justin808 May 5, 2026
db2c69e
Harden doctor base reference scanning
justin808 May 5, 2026
aa4971d
Avoid fragile encoding rescue in doctor scan
justin808 May 5, 2026
2f47435
Clarify doctor base reference scanner
justin808 May 5, 2026
5d92d98
Tighten doctor base reference patterns
justin808 May 5, 2026
2787d3b
Resolve doctor review feedback after main merge
justin808 May 8, 2026
0764cd5
Restore conditional FIX guidance
justin808 May 9, 2026
8d255a2
Merge main into doctor module specifier checks
justin808 May 9, 2026
1c81e00
Use canonical GitHub file link
justin808 May 9, 2026
88805bf
Harden doctor Pro package scan
justin808 May 9, 2026
02113fe
Harden base package reference scanning
justin808 May 9, 2026
783e45e
Clarify base package scan warnings
justin808 May 9, 2026
a21dc9c
Merge remote-tracking branch 'origin/main' into codex/doctor-pro-modu…
ihabadham May 14, 2026
75a864d
fix(pro_generator): rewrite jest.mock and declare module specifiers d…
ihabadham May 14, 2026
33e512a
fix(pro_generator): preserve user's Gemfile version pin during Pro ge…
ihabadham May 14, 2026
20ad179
fix(doctor): detect side-effect imports of the base package
ihabadham May 14, 2026
18df6d8
fix(pro_generator): rewrite version-pin regex to avoid ReDoS
ihabadham May 14, 2026
4acda83
fix(pro_generator): narrow rescue in base_react_on_rails_gem_in_gemfile?
ihabadham May 14, 2026
8e5ac75
refactor(pro_generator): hoist rewriter patterns to module-level cons…
ihabadham May 14, 2026
2700df9
fix(pro_generator): drop spurious bare importActual/importMock rewrit…
ihabadham May 15, 2026
edab0e4
Merge branch 'main' into codex/doctor-pro-module-specifiers
ihabadham May 15, 2026
460c9ea
docs(pro_setup): document the Gemfile-swap defer path in missing_pro_…
ihabadham May 15, 2026
794e61e
fix(doctor): correct inaccurate bare importActual/importMock comment
ihabadham May 15, 2026
ab821f9
fix(doctor): scan vue/svelte files to stay a superset of the rewriter
ihabadham May 15, 2026
53bb0d2
fix(doctor): skip node_modules in base package reference scan
ihabadham May 15, 2026
f117480
test(pro_generator): cover git: argument alongside the version pin
ihabadham May 15, 2026
6523a33
fix pro migration module specifier rewrites
ihabadham May 15, 2026
f5ae390
refactor pro migration shared handling
ihabadham May 15, 2026
48815a3
Avoid regex ambiguity in Pro migration parsing
ihabadham May 15, 2026
53bcb49
fix(doctor): rescue IOError in base_package_reference_file? for sibli…
ihabadham May 15, 2026
d34199a
chore(generator): track the CQS-smell refactor in an issue, not an in…
ihabadham May 15, 2026
7b8426b
fix(pro_migration): strip Ruby inline comments without a regex
ihabadham May 15, 2026
60ca5c3
fix pro gem swap for postfix guards
ihabadham May 16, 2026
bffd344
Merge remote-tracking branch 'origin/main' into codex/doctor-pro-modu…
ihabadham May 16, 2026
a134cf5
fix pro gem swap duplicate declarations
ihabadham May 16, 2026
fa798dc
fix pro gem swap to remove stale base gem when pro entry exists
ihabadham May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

#### Fixed

- **[Pro]** **Doctor detects more stale Pro migration imports**: `react_on_rails:doctor` now warns when Pro apps still reference the base package through Jest/Vitest mock helpers or TypeScript `declare module` blocks, catching migration leftovers that import/require scanning missed. See [Issue 3104](https://github.com/shakacode/react_on_rails/issues/3104). [PR 3232](https://github.com/shakacode/react_on_rails/pull/3232) by [justin808](https://github.com/justin808).
- **[Pro]** **Node renderer now exposes `performance` when `supportModules: true`**: React 19's development build of `React.lazy` calls `performance.now()`, which previously threw `ReferenceError: performance is not defined` inside the node renderer's VM context unless users manually added `performance` via `additionalContext`. `performance` is now included in the default globals alongside `Buffer`, `process`, etc. Fixes [Issue 3154](https://github.com/shakacode/react_on_rails/issues/3154). [PR 3158](https://github.com/shakacode/react_on_rails/pull/3158) by [justin808](https://github.com/justin808).
- **Client startup now recovers if initialization begins during `interactive` after `DOMContentLoaded` already fired**: React on Rails now still initializes the page when the client bundle starts in the browser timing window after `DOMContentLoaded` but before the document reaches `complete`. Fixes [Issue 3150](https://github.com/shakacode/react_on_rails/issues/3150). [PR 3151](https://github.com/shakacode/react_on_rails/pull/3151) by [ihabadham](https://github.com/ihabadham).
- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
Expand Down
69 changes: 42 additions & 27 deletions react_on_rails/lib/react_on_rails/doctor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2716,7 +2716,7 @@ def check_pro_setup
check_pro_initializer_existence
ensure_rails_environment_loaded
check_pro_renderer_mode
check_base_package_imports
check_base_package_references
end

def check_pro_initializer_existence
Expand Down Expand Up @@ -2747,44 +2747,59 @@ def check_pro_renderer_mode
end

# The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro',
# so `import ... from 'react-on-rails'` resolves silentlyloading the base package instead
# of Pro. Components registered through the base package won't have Pro features (streaming,
# caching, RSC), and may cause "component not registered" errors at runtime.
# so references to 'react-on-rails' resolve silently, loading the base package instead of Pro.
# Components registered through the base package won't have Pro features (streaming, caching,
# RSC), and may cause "component not registered" errors at runtime.
BASE_PACKAGE_IMPORT_PATTERN = %r{\bfrom\s+['"]react-on-rails(?:/[^'"]*)?['"]}
BASE_PACKAGE_REQUIRE_PATTERN = %r{\brequire\s*\(\s*['"]react-on-rails(?:/[^'"]*)?['"]\s*\)}
Comment thread
justin808 marked this conversation as resolved.
BASE_PACKAGE_MOCK_METHOD_NAMES = "mock|unmock|doMock|dontMock|requireActual|requireMock|importActual|importMock"
Comment thread
justin808 marked this conversation as resolved.
Outdated
BASE_PACKAGE_MOCK_PATTERN =
%r{\b(?:\w+\.)?(?:#{BASE_PACKAGE_MOCK_METHOD_NAMES})\s*\(\s*['"]react-on-rails(?:/[^'"]*)?['"]}
Comment thread
justin808 marked this conversation as resolved.
Outdated
# In Ruby, ^ matches the start of any line, so this catches declarations anywhere in the file.
BASE_PACKAGE_DECLARE_MODULE_PATTERN = %r{^\s*declare\s+module\s+['"]react-on-rails(?:/[^'"]*)?['"]}
Comment thread
justin808 marked this conversation as resolved.
Outdated
Comment thread
justin808 marked this conversation as resolved.
Outdated
BASE_PACKAGE_REFERENCE_PATTERNS = [
BASE_PACKAGE_IMPORT_PATTERN,
BASE_PACKAGE_REQUIRE_PATTERN,
BASE_PACKAGE_MOCK_PATTERN,
BASE_PACKAGE_DECLARE_MODULE_PATTERN
].freeze

def check_base_package_imports # rubocop:disable Metrics/CyclomaticComplexity
source_path = resolve_js_source_path
js_extensions = %w[js jsx ts tsx]
js_patterns = js_extensions.map { |ext| "#{source_path}/**/*.#{ext}" }
files_with_base_import = []

js_patterns.each do |pattern|
Dir.glob(pattern).each do |file|
content = File.read(file)
next unless content.match?(BASE_PACKAGE_IMPORT_PATTERN) || content.match?(BASE_PACKAGE_REQUIRE_PATTERN)

files_with_base_import << file
end
end
def check_base_package_references
files_with_base_reference = files_with_base_package_references(resolve_js_source_path)

if files_with_base_import.empty?
checker.add_success("✅ No base 'react-on-rails' imports found (Pro package used correctly)")
if files_with_base_reference.empty?
checker.add_success("✅ No base 'react-on-rails' references found (Pro package used correctly)")
else
checker.add_warning(<<~MSG.strip)
⚠️ Found imports from 'react-on-rails' instead of 'react-on-rails-pro':
#{files_with_base_import.map { |f| " • #{f}" }.join("\n")}
⚠️ Found references to 'react-on-rails' instead of 'react-on-rails-pro':
#{files_with_base_reference.map { |f| " • #{f}" }.join("\n")}
Comment thread
justin808 marked this conversation as resolved.

The base package is a transitive dependency of Pro, so these imports resolve
The base package is a transitive dependency of Pro, so these references resolve
silently but load the base version without Pro features.

Fix: Update imports to use 'react-on-rails-pro':
import ReactOnRails from 'react-on-rails-pro'; // server
import ReactOnRails from 'react-on-rails-pro/client'; // client
Fix: Replace base-package references with their Pro equivalents:
import ReactOnRails from 'react-on-rails-pro'; // ES import (server)
import ReactOnRails from 'react-on-rails-pro/client'; // ES import (client)
jest.mock('react-on-rails-pro', ...); // Jest/Vitest mock helper
Comment thread
justin808 marked this conversation as resolved.
Outdated
declare module 'react-on-rails-pro' { ... } // TypeScript augmentation
Comment thread
justin808 marked this conversation as resolved.
MSG
end
rescue StandardError => e
checker.add_warning("⚠️ Could not scan for base package imports: #{e.message}")
checker.add_warning("⚠️ Could not scan for base package references: #{e.message}")
end

def files_with_base_package_references(source_path)
js_extensions = %w[js jsx ts tsx]
Comment thread
justin808 marked this conversation as resolved.
Outdated
# The **/*.ts glob also includes .d.ts declaration files.
Comment thread
justin808 marked this conversation as resolved.
Outdated
js_patterns = js_extensions.map { |ext| "#{source_path}/**/*.#{ext}" }

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
js_patterns.flat_map do |pattern|
Comment thread
justin808 marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Dir.glob(pattern).select { |file| base_package_reference?(File.read(file)) }
Comment thread
justin808 marked this conversation as resolved.
Outdated
end
Comment thread
justin808 marked this conversation as resolved.
Outdated
end
Comment thread
justin808 marked this conversation as resolved.

def base_package_reference?(content)
BASE_PACKAGE_REFERENCE_PATTERNS.any? { |reference_pattern| content.match?(reference_pattern) }
end
Comment thread
justin808 marked this conversation as resolved.

# ── React Server Components ────────────────────────────────────
Expand Down
207 changes: 200 additions & 7 deletions react_on_rails/spec/lib/react_on_rails/doctor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2468,7 +2468,7 @@ class << self
end
end

describe "check_base_package_imports" do
describe "check_base_package_references" do
let(:doctor) { described_class.new(verbose: false, fix: false) }
let(:checker) { doctor.instance_variable_get(:@checker) }

Expand All @@ -2485,7 +2485,7 @@ class << self
end

it "reports warning with file paths" do
doctor.send(:check_base_package_imports)
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("react-on-rails") }).to be true
expect(warning_msgs.any? { |m| m[:content].include?("custom-bundle.js") }).to be true
Expand All @@ -2505,7 +2505,7 @@ class << self
end

it "reports warning" do
doctor.send(:check_base_package_imports)
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("react-on-rails") }).to be true
end
Expand All @@ -2524,12 +2524,142 @@ class << self
end

it "reports warning" do
doctor.send(:check_base_package_imports)
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("react-on-rails") }).to be true
end
end

context "when JS tests mock the base package after a Pro migration" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/packs")
File.write("app/javascript/packs/app.test.ts",
"jest.mock('react-on-rails', () => ({ authenticityHeaders: jest.fn() }));\n")
example.run
end
end
end

it "reports warning" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("app.test.ts") }).to be true
expect(warning_msgs.any? { |m| m[:content].include?("Found references to 'react-on-rails'") }).to be true
expect(warning_msgs.any? { |m| m[:content].include?("Jest/Vitest mock") }).to be true
Comment thread
justin808 marked this conversation as resolved.
Outdated
end
end

context "when JS tests mock a base package subpath after a Pro migration" do
Comment thread
justin808 marked this conversation as resolved.
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/packs")
File.write("app/javascript/packs/app.test.ts",
"jest.mock('react-on-rails/client', () => ({ authenticityHeaders: jest.fn() }));\n")
example.run
end
end
end

it "reports warning" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("app.test.ts") }).to be true
end
end

context "when Vitest tests import the actual base package after a Pro migration" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/packs")
File.write("app/javascript/packs/app.test.ts",
"const mod = await vi.importActual('react-on-rails/client');\n")
example.run
end
end
end

it "reports warning" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("app.test.ts") }).to be true
end
end

context "when Vitest tests import the base package mock without a receiver" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/packs")
File.write("app/javascript/packs/app.test.ts",
"const mod = await importMock('react-on-rails');\n")
example.run
end
end
end

it "reports warning" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("app.test.ts") }).to be true
end
end

context "when JS tests use additional mock helpers after a Pro migration" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/packs")
{
"unmock.test.ts" => "jest.unmock('react-on-rails');\n",
"do-mock.test.ts" => "jest.doMock('react-on-rails/client', () => ({}));\n",
"dont-mock.test.ts" => "jest.dontMock('react-on-rails');\n",
"require-actual.test.ts" => "const mod = jest.requireActual('react-on-rails');\n",
"require-mock.test.ts" => "const mod = jest.requireMock('react-on-rails/client');\n",
"vitest-mock.test.ts" => "vi.mock('react-on-rails', () => ({ authenticityHeaders: vi.fn() }));\n"
}.each do |filename, content|
File.write("app/javascript/packs/#{filename}", content)
end
example.run
end
end
end

it "reports warning for each stale helper reference" do
doctor.send(:check_base_package_references)
warning_content = checker.messages.select { |m| m[:type] == :warning }.map { |m| m[:content] }.join("\n")
expect(warning_content).to include("unmock.test.ts")
expect(warning_content).to include("do-mock.test.ts")
expect(warning_content).to include("dont-mock.test.ts")
expect(warning_content).to include("require-actual.test.ts")
expect(warning_content).to include("require-mock.test.ts")
expect(warning_content).to include("vitest-mock.test.ts")
end
Comment thread
justin808 marked this conversation as resolved.
end

context "when TypeScript declaration files augment the base package after a Pro migration" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/types")
File.write("app/javascript/types/react-on-rails.d.ts",
Comment thread
justin808 marked this conversation as resolved.
"declare module 'react-on-rails' {\n export function register(): void;\n}\n")
example.run
end
end
end

it "reports warning" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.size).to eq(1)
expect(warning_msgs.first[:content]).to include("react-on-rails.d.ts")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
end
end

context "when JS files correctly import from 'react-on-rails-pro'" do
around do |example|
Dir.mktmpdir do |tmpdir|
Expand All @@ -2543,8 +2673,71 @@ class << self
end

it "reports success" do
doctor.send(:check_base_package_imports)
doctor.send(:check_base_package_references)
success_msgs = checker.messages.select { |m| m[:type] == :success }
expect(success_msgs.any? { |m| m[:content].include?("Pro package used correctly") }).to be true
end
end

context "when JS tests correctly mock 'react-on-rails-pro'" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/packs")
File.write("app/javascript/packs/app.test.ts",
"jest.mock('react-on-rails-pro', () => ({ authenticityHeaders: jest.fn() }));\n")
example.run
end
end
end

it "reports success" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
success_msgs = checker.messages.select { |m| m[:type] == :success }
expect(warning_msgs).to be_empty
expect(success_msgs.any? { |m| m[:content].include?("Pro package used correctly") }).to be true
end
end

context "when Vitest tests correctly import actual 'react-on-rails-pro'" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/packs")
File.write("app/javascript/packs/app.test.ts",
"const mod = await vi.importActual('react-on-rails-pro');\n")
example.run
end
end
end

it "reports success" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
success_msgs = checker.messages.select { |m| m[:type] == :success }
expect(warning_msgs).to be_empty
expect(success_msgs.any? { |m| m[:content].include?("Pro package used correctly") }).to be true
end
end

context "when TypeScript declarations correctly augment 'react-on-rails-pro'" do
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
FileUtils.mkdir_p("app/javascript/types")
File.write("app/javascript/types/react-on-rails-pro.d.ts",
"declare module 'react-on-rails-pro' {\n export function register(): void;\n}\n")
example.run
end
end
end

it "reports success" do
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
success_msgs = checker.messages.select { |m| m[:type] == :success }
expect(warning_msgs).to be_empty
expect(success_msgs.any? { |m| m[:content].include?("Pro package used correctly") }).to be true
end
end
Expand All @@ -2557,7 +2750,7 @@ class << self
end

it "reports success (no files to scan)" do
doctor.send(:check_base_package_imports)
doctor.send(:check_base_package_references)
success_msgs = checker.messages.select { |m| m[:type] == :success }
expect(success_msgs.any? { |m| m[:content].include?("Pro package used correctly") }).to be true
end
Expand All @@ -2582,7 +2775,7 @@ class << self
end

it "scans the custom source_path and reports warning" do
doctor.send(:check_base_package_imports)
doctor.send(:check_base_package_references)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("react-on-rails") }).to be true
expect(warning_msgs.any? { |m| m[:content].include?("client/app/packs/app.js") }).to be true
Expand Down
Loading