diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md index 5f06bb958b..e7d49cb7d3 100644 --- a/.claude/commands/verify.md +++ b/.claude/commands/verify.md @@ -73,8 +73,8 @@ Use this order unless the changed files make a narrower or broader set clearly a - TypeScript package changes: run `pnpm run build`, package tests, `pnpm run lint`, and `pnpm run type-check`. - Generated examples or scripts: run the relevant generator/script command plus formatting and linting. - Documentation-only changes: run `pnpm start format.listDifferent`, sidebar validation for `docs/oss/` or `docs/pro/`, and `bin/check-links` for new or changed URLs. If committing, still run `bundle exec rubocop`; see Instructions step 3 for why this applies even to docs-only commits. RuboCop does not validate Markdown. -- `react_on_rails_pro/**/*.{js,ts,tsx,jsx,json,css,md}` changes: confirm the Pro package edit was approved per the `AGENTS.md` ask-first rule, then run `cd react_on_rails_pro && pnpm start format.listDifferent` (the Pro package's local Prettier check via its `nps` script). -- `react_on_rails_pro/**/*.rb` changes: confirm the Pro package edit was approved per the `AGENTS.md` ask-first rule, then run `bundle exec rubocop react_on_rails_pro/` and any targeted RSpec. +- `react_on_rails_pro/**/*.{js,ts,tsx,jsx,json,css,md}` changes: run `cd react_on_rails_pro && pnpm start format.listDifferent` (the Pro package's local Prettier check via its `nps` script) plus any focused tests for the changed surface. +- `react_on_rails_pro/**/*.rb` changes: run `(cd react_on_rails_pro && bundle exec rubocop --ignore-parent-exclusion)` and any targeted RSpec. - GitHub Actions workflow changes: confirm the edit was approved per the `AGENTS.md` ask-first rule, then run `actionlint` and `yamllint .github/`. Do not run RuboCop on `.yml` files. - Anything not listed above (for example, Rakefile edits, generator templates, RBS-only changes, or build scripts): apply the narrowest set of checks that covers the changed surface and explain the choice in the output. diff --git a/.claude/docs/3211-rsc-css-fouc-design.md b/.claude/docs/3211-rsc-css-fouc-design.md index f775cefad8..3d53a97e52 100644 --- a/.claude/docs/3211-rsc-css-fouc-design.md +++ b/.claude/docs/3211-rsc-css-fouc-design.md @@ -1,6 +1,6 @@ # Design: Fix CSS FOUC behind `'use client'` boundaries (issue #3211) -Status: approved 2026-06-03. Single PR (patch + renderer + tests). +Status: approved 2026-06-03. Single PR (released package + renderer + tests). > **Update 2026-06-04:** The upstream manifest fix landed in > `react-on-rails-rsc@19.0.5-rc.6`, which records `.css` siblings (plus `.mjs` @@ -19,8 +19,8 @@ seconds after first paint — producing a visible flash of unstyled content Two independent gaps cause this, and **both** must be closed: -1. **Manifest gap.** The patched `react-server-dom-webpack-plugin.js` shipped by - `react-on-rails-rsc@19.0.4` records only `.js` files for each client +1. **Manifest gap.** Versions of `react-server-dom-webpack-plugin.js` shipped + before `react-on-rails-rsc@19.0.5-rc.6` record only `.js` files for each client reference. The `mini-css-extract-plugin` CSS sibling of the same chunk group is dropped, so nothing downstream even _knows_ the CSS exists. 2. **Renderer gap.** The Pro RSC renderer never emits any CSS metadata — no @@ -36,10 +36,9 @@ manifest spec that traps gap #1. ## Why the fix is entirely in this repo -The upstream package fix (`shakacode/react_on_rails_rsc#35`) is still open and -unpublished. There is no fixed release to depend on. So the manifest half ships -here as a `pnpm patch` against `19.0.4`, mirroring what rsc#35 will eventually -publish; when a fixed release lands we drop the patch. +The upstream package fix now ships in `react-on-rails-rsc@19.0.5-rc.6`, so this +repo consumes the released package directly instead of carrying a local +`pnpm patch`. ## The mechanism we rely on: React 19 `` @@ -58,10 +57,9 @@ identically — so SSR and CSR are both covered with no CSR-specific code. ## Design -### Part A — Manifest plugin patch (record CSS) +### Part A — Manifest package fix (record CSS) -`patches/react-on-rails-rsc@19.0.4.patch`, applied via pnpm -`patchedDependencies`, rewrites the chunk-collection loop in +`react-on-rails-rsc@19.0.5-rc.6` rewrites the chunk-collection loop in `dist/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js`. Current (buggy) inner loop records at most the first `.js` per chunk and `break`s @@ -182,14 +180,13 @@ navigation they decode and React hoists/dedupes/suspends commit identically. | File | Change | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `package.json` (`pnpm.patchedDependencies`) | register the patch | -| `patches/react-on-rails-rsc@19.0.4.patch` _(new)_ | collect `.css` into `css[]` | +| `package.json` / workspace package manifests | consume `react-on-rails-rsc@19.0.5-rc.6` with the upstream CSS manifest fix | | `packages/react-on-rails-pro/src/resolveCssHrefs.ts` _(new)_ | manifest → ordered deduped hrefs | | `cache/` client-manifest accessor | cached read of client manifest for the renderer, invalidated by file signature | | `packages/react-on-rails-pro/src/capabilities/proRSC.ts` | wrap tree with `` before `renderToPipeableStream` | | `…/spec/dummy/spec/requests/rsc_use_client_css_manifest_spec.rb` | unpend; assert `css` | | `…/spec/dummy/e2e-tests/` | assert `` in `
` + computed bg color | -| `react_on_rails_rsc` types (consumed) | `ImportManifestEntry.css?: string[]` (via patch + local type) | +| `react_on_rails_rsc` types (consumed) | `ImportManifestEntry.css?: string[]` | ## Migration / compatibility diff --git a/.claude/skills/autoreview/SKILL.md b/.claude/skills/autoreview/SKILL.md index a3a5ce0972..4f16e84e47 100644 --- a/.claude/skills/autoreview/SKILL.md +++ b/.claude/skills/autoreview/SKILL.md @@ -46,7 +46,7 @@ This is the portable core. Hold it regardless of which engine runs. `AGENTS.md` high-risk / `full-ci` / `benchmark` categories. Run it after the primary review is clean, keep it to one extra pass, and verify its findings the same way. - If you reject a finding as intentional/not worth fixing, add a brief inline code comment only when it documents a real invariant or ownership decision a future reviewer should know. -- **Do not push just to review.** Push only when the user asked for push/ship/PR. Follow `AGENTS.md` git boundaries (Pro package edits are ask-first; never force-push `main`/`master`). +- **Do not push just to review.** Push only when the user asked for push/ship/PR. Follow `AGENTS.md` git boundaries (never force-push `main`/`master`). ## Step 1 - Pick the target @@ -98,7 +98,7 @@ Use `AGENTS.md` and `/verify` for the actual check set. Before a closeout review ```bash (cd react_on_rails && bundle exec rubocop) # CI-equivalent OSS Ruby lint -# Also, only when Pro Ruby or RuboCop config changed (Pro edits are ask-first per AGENTS.md): +# Also, only when Pro Ruby or RuboCop config changed: (cd react_on_rails_pro && bundle exec rubocop --ignore-parent-exclusion) ``` diff --git a/.github/workflows/pro-test-package-and-gem.yml b/.github/workflows/pro-test-package-and-gem.yml index 9af26a0f5b..896b3d8695 100644 --- a/.github/workflows/pro-test-package-and-gem.yml +++ b/.github/workflows/pro-test-package-and-gem.yml @@ -375,6 +375,9 @@ jobs: JEST_JUNIT_OUTPUT_DIR: ./jest JEST_JUNIT_ADD_FILE_ATTRIBUTE: "true" + - name: Run Pro dummy Jest unit tests + run: cd spec/dummy && pnpm run test:js + - name: Store test results uses: actions/upload-artifact@v4 if: always() diff --git a/.lychee.toml b/.lychee.toml index 62a132a0f3..4f23279dd0 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -73,6 +73,7 @@ exclude = [ '^https://tanstack\.com/query/latest/docs/framework/react/guides/ssr$', # Connection failed from CI '^https://lodash\.com', # Connection reset from CI '^https://vite-ruby\.netlify\.app/?$', # Intermittent connection resets from CI + '^https://www\.developerway\.com/posts/react-server-components-performance$', # Returns 503 from CI # ============================================================================ # PLANNED DEPLOYMENTS NOT YET LIVE diff --git a/AGENTS.md b/AGENTS.md index 1d727c9bca..9d1ca1ae56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,7 @@ For small, focused PRs (roughly 5 files changed or fewer and one clear purpose): - Ensure all files end with a newline - Let Prettier and RuboCop handle formatting — never format manually - When adding docs under `docs/oss/` or `docs/pro/`, also add the doc ID to `docs/sidebars.ts` and run `script/check-docs-sidebar` — CI will fail otherwise. To intentionally exclude a doc from the sidebar, add its ID to `docs/.sidebar-exclusions` with a reason comment. +- Pro package edits do not require special approval beyond the normal boundaries below; run the Pro-specific lint/tests that cover the changed files. ### Ask First diff --git a/CHANGELOG.md b/CHANGELOG.md index c93e844f0e..f7b390b178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ ### [Unreleased] +#### Added + +- **[Pro]** **RSC manifest client reference discovery during precompile**: Generated RSC Webpack configs now run `RSCReferenceDiscoveryPlugin` through the Shakapacker precompile hook to emit `rsc-client-references.json`, use that manifest for RSC client-reference bundling, and warn when the selected manifest is stale. The generator now pins `react-on-rails-rsc` to `19.0.5-rc.6`, the prerelease containing the discovery plugin export and RSC manifest CSS fixes, and the Pro peer range explicitly accepts that prerelease. [PR 3556](https://github.com/shakacode/react_on_rails/pull/3556) by [ihabadham](https://github.com/ihabadham). + #### Changed - **[Pro]** **Updated the RSC rollout pin to `react-on-rails-rsc@19.0.5-rc.6`**: Pro RSC install docs, generator defaults, package metadata, and example guidance now point at `19.0.5-rc.6` while keeping React on the supported `19.0.x` range and documenting the temporary exact prerelease pin. `19.0.5-rc.6` ships the client-manifest CSS collection fix (record `.css` siblings, `.mjs` chunk support, href normalization) upstream, so the temporary local pnpm patch (`patches/react-on-rails-rsc@19.0.5-rc.5.patch`) has been removed. [PR 3577](https://github.com/shakacode/react_on_rails/pull/3577) by [justin808](https://github.com/justin808). @@ -36,9 +40,8 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ #### Fixed +- **[Pro]** **RSC CSS no longer flashes unstyled (FOUC) behind `'use client'` boundaries**: CSS imported by a `'use client'` boundary in a true React Server Component tree is now preloaded instead of loading only as a side effect of the JS chunk evaluating. The published `react-on-rails-rsc@19.0.5-rc.6` package now records each client reference's `.css` siblings in the RSC client manifest, and the Pro RSC renderer emits `` for them inside the RSC payload so React 19 hoists the stylesheets into `` and blocks paint until they load — on both server render and client-side navigation. Fixes [Issue 3211](https://github.com/shakacode/react_on_rails/issues/3211). [PR 3587](https://github.com/shakacode/react_on_rails/pull/3587) by [justin808](https://github.com/justin808). - **[Pro]** **`react-on-rails-rsc` prerelease (RC) versions no longer mark the dependency tree invalid**: The `react-on-rails-pro` peer dependency on the optional `react-on-rails-rsc` is now `*`, so installing any coordinated `react-on-rails-rsc` build — including prereleases such as `react-on-rails-rsc@19.0.5-rc.6` — no longer makes `npm ls react-on-rails-rsc` fail with `ELSPROBLEMS`. npm's strict semver only lets a prerelease satisfy a comparator that shares its exact `major.minor.patch` tuple, so no bounded range — including the `>= 19.0.2 < 20.0.0` range introduced in [PR 3580](https://github.com/shakacode/react_on_rails/pull/3580) — can admit prereleases across the React 19 line (e.g. `19.0.5-rc.6`, `19.2.x-rc.*`) without enumerating every patch tuple. `react-on-rails-rsc` stays an optional peer that Pro resolves only on the React Server Components path; the supported pairing is React on Rails RSC on the React 19 line (currently `>= 19.0.2`), and a mismatched build fails loudly at bundle time through Pro's `react-on-rails-rsc/*` imports rather than relying on the peer-range warning. Fixes [Issue 3609](https://github.com/shakacode/react_on_rails/issues/3609). [PR 3616](https://github.com/shakacode/react_on_rails/pull/3616) by [justin808](https://github.com/justin808). - -- **[Pro]** **RSC CSS no longer flashes unstyled (FOUC) behind `'use client'` boundaries**: CSS imported by a `'use client'` boundary in a true React Server Component tree is now preloaded instead of loading only as a side effect of the JS chunk evaluating. The RSC client manifest now records each client reference's `.css` siblings (via a `pnpm` patch to `react-on-rails-rsc` until the upstream fix is published), and the Pro RSC renderer emits `` for them inside the RSC payload so React 19 hoists the stylesheets into `` and blocks paint until they load — on both server render and client-side navigation. Fixes [Issue 3211](https://github.com/shakacode/react_on_rails/issues/3211). [PR 3587](https://github.com/shakacode/react_on_rails/pull/3587) by [justin808](https://github.com/justin808). - **[Pro]** **Client teardown failures are no longer hidden at `console.info`**: when `ComponentRenderer.unmount()` catches an error from `unmountComponentAtNode` (the React 16/17 legacy unmount path), it now logs at `console.error` instead of `console.info`. A caught error there means the component tree did not unmount cleanly — a teardown failure — and most log collectors and default browser-console filters drop `info`, so the failure was effectively silent. Addresses item 2 of [Issue 3592](https://github.com/shakacode/react_on_rails/issues/3592). [PR 3610](https://github.com/shakacode/react_on_rails/pull/3610) by [justin808](https://github.com/justin808). ### [17.0.0.rc.1] - 2026-06-02 diff --git a/analysis/rsc-fouc-shakaperf-artifacts/setup/generated-shakaperf-skills/discover-abtests.SKILL.md b/analysis/rsc-fouc-shakaperf-artifacts/setup/generated-shakaperf-skills/discover-abtests.SKILL.md index e25bfe8b9e..fba8ec0c99 100644 --- a/analysis/rsc-fouc-shakaperf-artifacts/setup/generated-shakaperf-skills/discover-abtests.SKILL.md +++ b/analysis/rsc-fouc-shakaperf-artifacts/setup/generated-shakaperf-skills/discover-abtests.SKILL.md @@ -132,9 +132,9 @@ Check for spinners, skeleton screens, loading indicators. Use `javascript_tool` - Dropdowns (`select`, custom dropdowns) - Textareas - Checkboxes and radio buttons - + **Try filling them during probing** — actually type values into inputs, select dates on calendars, increment number fields, check checkboxes. This confirms what works and what doesn't before you write test code. - + **Fill before clicking action buttons.** When a form has both inputs and a submit/action button (like "Book Now", "Search", "Apply"), the right test sequence is: fill all inputs first → capture the filled state → then click the button. A test that clicks "Book Now" without filling in dates and guests misses the most interesting UI state (the populated form) and may also miss validation behavior. **A7. Probe inside modals/expanded UI**: when clicking reveals new UI (modal, drawer, expanded panel), probe THAT UI for its own interactive elements — buttons, forms, links within the modal. Keep going as long as new testable UI appears. For each form inside a modal, record all fields so you can write a form-fill test in Step B. diff --git a/packages/react-on-rails-pro/src/resolveCssHrefs.ts b/packages/react-on-rails-pro/src/resolveCssHrefs.ts index 6ac4d37c24..5ee4c4de67 100644 --- a/packages/react-on-rails-pro/src/resolveCssHrefs.ts +++ b/packages/react-on-rails-pro/src/resolveCssHrefs.ts @@ -34,6 +34,8 @@ export type RscCssManifest = { const joinPrefix = (prefix: string, file: string): string => { if (!prefix) return file; const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; + // Manifest CSS paths are usually root-relative, but callers may pass already + // prefixed or absolute hrefs; avoid double-prefixing either shape. if ( file === base || file.startsWith(`${base}/`) || diff --git a/packages/react-on-rails-pro/tests/resolveCssHrefs.test.ts b/packages/react-on-rails-pro/tests/resolveCssHrefs.test.ts index 780899dd3d..0cb58a9e20 100644 --- a/packages/react-on-rails-pro/tests/resolveCssHrefs.test.ts +++ b/packages/react-on-rails-pro/tests/resolveCssHrefs.test.ts @@ -73,6 +73,38 @@ describe('resolveCssHrefs', () => { ).toEqual(['https://cdn.example.com/assets/css/a.css', 'https://cdn.example.com/assets/css/b.css']); }); + it('does not double-prefix root-relative css files that already include the webpack prefix', () => { + expect( + resolveCssHrefs({ + moduleLoading: { prefix: '/webpack/test/' }, + filePathToModuleMetadata: { + 'file:///app/SimpleClientComponent.jsx': { + id: '1', + chunks: [], + css: ['/webpack/test/css/client5-6dd89694.css'], + name: '*', + }, + }, + }), + ).toEqual(['/webpack/test/css/client5-6dd89694.css']); + }); + + it('still prefixes a root-relative css file that does not already include the webpack prefix', () => { + expect( + resolveCssHrefs({ + moduleLoading: { prefix: '/webpack/test/' }, + filePathToModuleMetadata: { + 'file:///app/SimpleClientComponent.jsx': { + id: '1', + chunks: [], + css: ['/css/client5-6dd89694.css'], + name: '*', + }, + }, + }), + ).toEqual(['/webpack/test/css/client5-6dd89694.css']); + }); + it('treats a missing prefix as empty (bare hrefs)', () => { expect( resolveCssHrefs({ diff --git a/react_on_rails/.rubocop.yml b/react_on_rails/.rubocop.yml index 6e198842e9..b40c03a07c 100644 --- a/react_on_rails/.rubocop.yml +++ b/react_on_rails/.rubocop.yml @@ -11,6 +11,7 @@ AllCops: Exclude: - 'spec/dummy/bin/*' + - 'spec/react_on_rails/dummy-for-generators/**/*' # Generated fixture contains intentionally invalid Ruby - 'spike/**/*' # Exploratory spike code outside lib/ — not part of the production surface Naming/FileName: diff --git a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb index 2db4873a0a..265b36e25a 100644 --- a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb +++ b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb @@ -145,18 +145,9 @@ module JsDependencyManager # which pins `react-on-rails-rsc`. The two track different versions during the RC window: # react/react-dom stay on stable 19.0.x while react-on-rails-rsc rides an RC. RSC_REACT_VERSION_RANGE = "~19.0.4" - # Pinned to 19.0.5-rc.x because the native rspack manifest plugin - # (`react-on-rails-rsc/RspackPlugin`) the generator scaffolds for rspack projects is only - # exported from 19.0.5 onward. This single pin applies to ALL `--rsc` installs, so webpack - # projects are also pinned to this RC during the pre-stable window (intentional: the RC is - # backward-compatible for webpack — it still exports `react-on-rails-rsc/WebpackPlugin`). The - # #3488 stable bump below moves every bundler to 19.0.5 at once. - # TODO(#3488): when react-on-rails-rsc 19.0.5 stable ships, bump RSC_PACKAGE_VERSION_PIN to - # "19.0.5" (and RSC_REACT_VERSION_RANGE too if react/react-dom advance), then verify peer-dep - # alignment between react@19.0.x and react-on-rails-rsc@19.0.5. At that point the `latest` - # tag exports RspackPlugin, so the rspack-only fallback skip in add_rsc_dependencies can be - # removed (rspack can safely share the webpack unversioned-fallback path again). - # (tracked in https://github.com/shakacode/react_on_rails/issues/3488). + # Pinned to 19.0.5-rc.6 because the discovery plugin export, native Rspack plugin, and + # RSC manifest CSS fixes all ship in that prerelease. + # TODO(#3642): switch to a stable react-on-rails-rsc release after 19.0.5 stable ships. RSC_PACKAGE_VERSION_PIN = "19.0.5-rc.6" private @@ -250,7 +241,7 @@ def add_react_dependencies say "Installing React dependencies..." # RSC requires React 19.0.x specifically (not 19.1.x or later) - # Pin to ~19.0.4 to allow patch updates while staying within 19.0.x + # Pin React to ~19.0.4 while using an RSC package release that exports manifest discovery. react_deps = if respond_to?(:use_rsc?) && use_rsc? ["react@#{RSC_REACT_VERSION_RANGE}", "react-dom@#{RSC_REACT_VERSION_RANGE}", "prop-types@^15.0.0"] diff --git a/react_on_rails/lib/generators/react_on_rails/rsc_setup/client_references.rb b/react_on_rails/lib/generators/react_on_rails/rsc_setup/client_references.rb index 6f5e3a26c8..7e66ebb0aa 100644 --- a/react_on_rails/lib/generators/react_on_rails/rsc_setup/client_references.rb +++ b/react_on_rails/lib/generators/react_on_rails/rsc_setup/client_references.rb @@ -27,15 +27,132 @@ module ClientReferences # rubocop:disable Metrics/ModuleLength private + # rubocop:disable Metrics/MethodLength -- emits a JS template; line count tracks the heredoc. def rsc_client_references_js <<~'JS'.chomp - const rscClientReferences = { + const fallbackRscClientReferences = { directory: resolve(config.source_path), recursive: true, include: /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/, }; + + // Resolves the RSC client-reference manifest produced by the discovery build. The + // manifest is a point-in-time snapshot regenerated by bin/shakapacker-precompile-hook, + // not on every webpack rebuild: a long-running `--watch` build reads it once at startup, + // so a new 'use client' file added mid-session is not picked up until the watch restarts + // (which re-runs the hook). Running bin/shakapacker directly after changing server + // components is covered by the best-effort staleness warning below. + // + // The resolution cascade below is mirrored, branch for branch, by the Pro dummy's + // hand-written rscManifestClientReferences.js and pinned on both sides by contract tests. + const rscClientReferences = (() => { + // fs is required inside the IIFE rather than at the top of the file because the + // module-scope bindings 'resolve' (from 'path') and 'config' (from 'shakapacker') are + // already present — either pre-existing in the config or injected alongside this + // snippet by the generator — so only fs is pulled in here. + const { existsSync, readFileSync, statSync } = require('fs'); + const configuredRefsJson = process.env.RSC_MANIFEST_CLIENT_REFERENCES_JSON; + const defaultRefsJson = resolve('ssr-generated/rsc-client-references.json'); + const serverComponentRegistrationEntry = resolve( + config.source_path, + config.source_entry_path, + '../generated/server-component-registration-entry.js', + ); + + const readManifestReferences = (refsJson) => { + let payload; + try { + payload = JSON.parse(readFileSync(refsJson, 'utf8')); + } catch (err) { + throw new Error(`Failed to parse RSC client references manifest ${refsJson}: ${err.message}`); + } + if (!Array.isArray(payload.refs)) { + throw new Error(`Expected ${refsJson} to contain a refs array`); + } + + return payload.refs; + }; + + const warnIfManifestStale = (refsJson) => { + try { + if ( + existsSync(serverComponentRegistrationEntry) && + statSync(serverComponentRegistrationEntry).mtimeMs > statSync(refsJson).mtimeMs + ) { + console.warn( + `[react_on_rails] ${refsJson} is older than the server component ` + + 'registration entry; RSC client references may be stale. ' + + 'Re-run bin/shakapacker-precompile-hook.', + ); + } + } catch { + // The manifest warning is non-fatal; the file can disappear between existsSync + // and statSync while another build is rewriting ssr-generated. + } + }; + + const fileContainsAll = (filePath, tokens) => { + try { + if (!existsSync(filePath)) return false; + + const content = readFileSync(filePath, 'utf8'); + return tokens.every((token) => content.includes(token)); + } catch { + return false; + } + }; + + const rscConfigSupportsDiscovery = () => { + const rscWebpackConfig = resolve('config/webpack/rscWebpackConfig.js'); + const precompileHook = resolve('bin/shakapacker-precompile-hook'); + + return ( + fileContainsAll(rscWebpackConfig, ['RSC_REFERENCE_DISCOVERY_BUILD', 'RSCReferenceDiscoveryPlugin']) && + fileContainsAll(precompileHook, [ + 'generate_rsc_manifest_client_references_if_needed', + 'RSC_REFERENCE_DISCOVERY_BUILD', + ]) + ); + }; + + if (configuredRefsJson) { + const resolvedRefsJson = resolve(configuredRefsJson); + if (!existsSync(resolvedRefsJson)) { + throw new Error( + `RSC_MANIFEST_CLIENT_REFERENCES_JSON is set but the file does not exist: ${resolvedRefsJson}`, + ); + } + warnIfManifestStale(resolvedRefsJson); + return readManifestReferences(resolvedRefsJson); + } + + if (process.env.RSC_REFERENCE_DISCOVERY_BUILD === 'true' || process.env.RSC_BUNDLE_ONLY === 'true') { + return [fallbackRscClientReferences]; + } + + if (existsSync(defaultRefsJson)) { + warnIfManifestStale(defaultRefsJson); + return readManifestReferences(defaultRefsJson); + } + + if (existsSync(serverComponentRegistrationEntry)) { + if (!rscConfigSupportsDiscovery()) { + console.warn( + `[react_on_rails] Missing ${defaultRefsJson}, but this app's RSC webpack config ` + + 'or precompile hook does not support manifest discovery yet; falling back to broad client ' + + 'reference scan. Re-run rails g react_on_rails:rsc to update generated configs.', + ); + return [fallbackRscClientReferences]; + } + + throw new Error(`Missing ${defaultRefsJson}. Run bin/shakapacker-precompile-hook before bin/shakapacker.`); + } + + return [fallbackRscClientReferences]; + })(); JS end + # rubocop:enable Metrics/MethodLength def inject_rsc_client_imports(config_path, content, existing_imports_content) replace_rsc_client_references_setup_anchor(config_path, content, is_server: false) do |anchor| @@ -89,11 +206,7 @@ def prepare_rsc_plugin_imports(config_path, content, existing_imports_content, i # plugin-import-only path and let downstream callers honor the scoped/unscoped # state of whatever the user already wrote. if rsc_client_references_defined?(content) - return :failed unless inject_rsc_plugin_import(config_path, content, is_server: is_server) - return :scoped if scoped_rsc_client_references_defined?(content) - - warn_unscoped_rsc_client_references_helper(config_path) - return :unscoped + return prepare_existing_rsc_client_references_plugin_import(config_path, content, is_server: is_server) end return :failed unless inject_rsc_imports(config_path, content, existing_imports_content, is_server: is_server) @@ -102,6 +215,21 @@ def prepare_rsc_plugin_imports(config_path, content, existing_imports_content, i :failed end + def prepare_existing_rsc_client_references_plugin_import(config_path, content, is_server:) + return :failed unless inject_rsc_plugin_import(config_path, content, is_server: is_server) + + content = read_current_rsc_config(config_path, fallback: content) + if object_literal_rsc_client_references_defined?(content) + return :failed unless replace_legacy_rsc_client_references_setup(config_path, content) + + content = read_current_rsc_config(config_path, fallback: content) + end + return :scoped if generated_rsc_client_references_defined?(content) + + warn_unscoped_rsc_client_references_helper(config_path) + :unscoped + end + def inject_rsc_imports(config_path, content, existing_imports_content, is_server:) inserted = if is_server @@ -340,7 +468,11 @@ def warn_unsupported_rsc_plugin_import_syntax(config_path) end def ensure_rsc_client_references_setup(config_path, content, is_server:) - return true if scoped_rsc_client_references_defined?(content) + return true if generated_rsc_client_references_defined?(content) + + if object_literal_rsc_client_references_defined?(content) + return replace_legacy_rsc_client_references_setup_ready?(config_path, content) + end if rsc_client_references_defined?(content) warn_unscoped_rsc_client_references_helper(config_path) @@ -363,8 +495,15 @@ def ensure_rsc_client_references_setup(config_path, content, is_server:) rsc_client_references_setup_ready?(config_path) end + def replace_legacy_rsc_client_references_setup_ready?(config_path, content) + return false unless replace_legacy_rsc_client_references_setup(config_path, content) + return true if options[:skip] + + rsc_client_references_setup_ready?(config_path) + end + def rsc_plugin_uses_scoped_client_references?(content, is_server:) - scoped_rsc_client_references_defined?(content) && + generated_rsc_client_references_defined?(content) && rsc_plugin_references_scoped_client_references?(content, is_server: is_server) end @@ -1195,6 +1334,21 @@ def add_rsc_client_references_setup(config_path, content, existing_imports_conte end end + def replace_legacy_rsc_client_references_setup(config_path, content) + range_start, range_end = scoped_object_literal_range(content, "rscClientReferences") + return false unless range_start + + updated_content = content.dup + updated_content[range_start...range_end] = rsc_client_references_js + write_existing_rsc_config(config_path, updated_content, action: :rewrite) + end + + def read_current_rsc_config(config_path, fallback:) + return fallback if options[:pretend] || options[:skip] + + File.read(File.join(destination_root, config_path)) + end + def replace_rsc_client_references_setup_anchor(config_path, content, is_server:) anchor_match = rsc_client_references_setup_anchor_match(content, is_server: is_server) return unless anchor_match @@ -1295,25 +1449,61 @@ def rsc_client_references_defined?(content) end def scoped_rsc_client_references_defined?(content) + object_literal_rsc_client_references_defined?(content) || + generated_rsc_client_references_defined?(content) + end + + def object_literal_rsc_client_references_defined?(content) # Locate the actual module-scope `const|let|var rscClientReferences = { ... }` site and # check the `directory:` key against the object literal body with comments stripped. # Running the regex against the raw file would treat a stale, commented-out # `// directory: resolve(config.source_path)` (e.g. left over from a prior failed # migration) as a real scoped declaration and silently short-circuit # `ensure_rsc_client_references_setup`, leaving the plugin unscoped without any warning. - decl_pattern = /^[ \t]*(?:const|let|var)\s+rscClientReferences\s*=\s*\{/ - content.to_enum(:scan, decl_pattern).any? do + scoped_object_literal_defined?(content, "rscClientReferences") + end + + def generated_rsc_client_references_defined?(content) + # Detect the generated `const rscClientReferences = (() => { ... })()` form + # structurally rather than by substring-matching template internals such as the + # `RSC_MANIFEST_CLIENT_REFERENCES_JSON` env-var name or the `rsc-client-references.json` + # filename. Those `content.include?` guards would couple detection to implementation + # details — renaming either string would silently break detection without a failing + # test — while adding no safety: `scoped_object_literal_defined?` already requires a + # real, module-scope `fallbackRscClientReferences` object literal with + # `directory: resolve(config.source_path)` (the generator-specific signature), and the + # `rsc_client_references_defined?` precondition rejects commented-out declarations — its + # `^[ \t]*(?:const|let|var)` anchor skips `//`-prefixed lines, and `js_top_level_position?` + # rejects declarations buried inside `/* ... */` blocks. + return false unless rsc_client_references_defined?(content) + + scoped_object_literal_defined?(content, "fallbackRscClientReferences") + end + + def scoped_object_literal_defined?(content, variable_name) + !!scoped_object_literal_range(content, variable_name) + end + + def scoped_object_literal_range(content, variable_name) + decl_pattern = /^[ \t]*(?:const|let|var)\s+#{Regexp.escape(variable_name)}\s*=\s*\{/ + content.to_enum(:scan, decl_pattern).each do match = Regexp.last_match - next false unless js_top_level_position?(content, match.begin(0)) + next unless js_top_level_position?(content, match.begin(0)) open_brace = match.end(0) - 1 close_brace = matching_js_closing_brace(content, open_brace) - next false unless close_brace + next unless close_brace body = content[(open_brace + 1)...close_brace] - rsc_plugin_options_without_comments(body) - .match?(/\bdirectory\s*:\s*resolve\(\s*config\.source_path\s*\)/) + stripped_body = rsc_plugin_options_without_comments(body) + next unless stripped_body.match?(/\bdirectory\s*:\s*resolve\(\s*config\.source_path\s*\)/) + + range_end = close_brace + 1 + range_end += 1 if content[range_end] == ";" + return [match.begin(0), range_end] end + + nil end # Inclusive slice — the anchor itself is part of the returned content because callers @@ -1459,7 +1649,7 @@ def rsc_client_references_setup_ready?(config_path, plugin_pending: false) # output) instead. return true if options[:pretend] return true if options[:skip] - return true if scoped_rsc_client_references_defined?(File.read(File.join(destination_root, config_path))) + return true if generated_rsc_client_references_defined?(File.read(File.join(destination_root, config_path))) warn_rsc_client_references_injection_failed(config_path, plugin_pending: plugin_pending) false diff --git a/react_on_rails/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-precompile-hook b/react_on_rails/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-precompile-hook index effd85a22c..4968214f41 100755 --- a/react_on_rails/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-precompile-hook +++ b/react_on_rails/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-precompile-hook @@ -23,11 +23,57 @@ exit 0 if ENV["SHAKAPACKER_SKIP_PRECOMPILE_HOOK"] == "true" # and doesn't need package version validation since it's part of the build itself ENV["REACT_ON_RAILS_SKIP_VALIDATION"] = "true" +require "pathname" +require "fileutils" require_relative "../config/environment" +def clear_stale_rsc_manifest_client_references + stale_manifest = Rails.root.join("ssr-generated", "rsc-client-references.json") + FileUtils.rm_f(stale_manifest) +end + +# Keep this flow aligned with the standalone helper in +# react_on_rails/spec/support/shakapacker_precompile_hook_shared.rb. +def generate_rsc_manifest_client_references_if_needed + return if ENV["RSC_REFERENCE_DISCOVERY_BUILD"] == "true" + return unless defined?(ReactOnRailsPro) + return unless ReactOnRailsPro::Utils.rsc_support_enabled? + + registration_entry = Rails.root.join( + Pathname(ReactOnRails::PackerUtils.packer_source_entry_path).parent, + "generated/server-component-registration-entry.js" + ) + unless File.exist?(registration_entry) + clear_stale_rsc_manifest_client_references + return + end + + shakapacker_bin = Rails.root.join("bin", "shakapacker") + unless File.exist?(shakapacker_bin) + raise "bin/shakapacker is missing; cannot generate RSC manifest client references. " \ + "Restore bin/shakapacker before precompiling RSC assets." + end + + puts Rainbow("🔎 Generating RSC manifest client references...").cyan + + env = { + "SHAKAPACKER_SKIP_PRECOMPILE_HOOK" => "true", + "RSC_REFERENCE_DISCOVERY_BUILD" => "true", + "RSC_BUNDLE_ONLY" => "true", + "CLIENT_BUNDLE_ONLY" => nil, + "SERVER_BUNDLE_ONLY" => nil + } + + Dir.chdir(Rails.root) do + system(env, shakapacker_bin.to_s, exception: true) + end + puts Rainbow("✅ RSC manifest client references generated successfully").green +end + begin puts Rainbow("🔄 Running React on Rails precompile hook...").cyan ReactOnRails::PacksGenerator.instance.generate_packs_if_stale + generate_rsc_manifest_client_references_if_needed rescue StandardError => e warn Rainbow("❌ Error in precompile hook: #{e.message}").red warn e.backtrace.first(5).join("\n") diff --git a/react_on_rails/lib/generators/react_on_rails/templates/rsc/base/config/webpack/rscWebpackConfig.js.tt b/react_on_rails/lib/generators/react_on_rails/templates/rsc/base/config/webpack/rscWebpackConfig.js.tt index cadfbb03e9..6fde884791 100644 --- a/react_on_rails/lib/generators/react_on_rails/templates/rsc/base/config/webpack/rscWebpackConfig.js.tt +++ b/react_on_rails/lib/generators/react_on_rails/templates/rsc/base/config/webpack/rscWebpackConfig.js.tt @@ -2,8 +2,24 @@ // This creates the RSC bundle based on the server webpack config // See: https://reactonrails.com/docs/pro/react-server-components/ +const { existsSync } = require('fs'); +const { resolve } = require('path'); +const { config } = require('shakapacker'); const serverWebpackModule = require('./serverWebpackConfig'); +const rscReferenceDiscoveryPlugin = () => { + try { + return require('react-on-rails-rsc/RSCReferenceDiscoveryPlugin').RSCReferenceDiscoveryPlugin; + } catch (error) { + throw new Error( + `Missing react-on-rails-rsc/RSCReferenceDiscoveryPlugin. ` + + `Install react-on-rails-rsc with RSCReferenceDiscoveryPlugin support ` + + `(check package.json for the required peer range) before running ` + + `bin/shakapacker-precompile-hook. ${error.message}`, + ); + } +}; + // Backward compatibility: // - New Pro config exports: { default: configureServer, extractLoader } // - Legacy config exports: module.exports = configureServer @@ -21,12 +37,31 @@ const extractLoader = const configureRsc = () => { // Pass true to skip the RSC manifest plugin - RSC bundle doesn't need it const rscConfig = serverWebpackConfig(true); + const discoveryBuild = process.env.RSC_REFERENCE_DISCOVERY_BUILD === 'true'; - // Update the entry name to be `rsc-bundle` instead of `server-bundle` - const rscEntry = { - 'rsc-bundle': rscConfig.entry['server-bundle'], - }; - rscConfig.entry = rscEntry; + const serverComponentRegistrationEntry = resolve( + config.source_path, + config.source_entry_path, + '../generated/server-component-registration-entry.js', + ); + + if (discoveryBuild) { + if (!existsSync(serverComponentRegistrationEntry)) { + throw new Error( + `Missing server component registration entry: ${serverComponentRegistrationEntry}. ` + + `Run bin/shakapacker-precompile-hook before bin/shakapacker.`, + ); + } + + rscConfig.entry = { + 'rsc-reference-discovery': serverComponentRegistrationEntry, + }; + } else { + // Update the entry name to be `rsc-bundle` instead of `server-bundle` + rscConfig.entry = { + 'rsc-bundle': rscConfig.entry['server-bundle'], + }; + } // Add the RSC WebpackLoader to the JS rule's loader chain. // This loader replaces 'use client' files with registerClientReference proxies in the RSC bundle. @@ -74,8 +109,14 @@ const configureRsc = () => { }, }; - // Update the output bundle name to be `rsc-bundle.js` instead of `server-bundle.js` - rscConfig.output.filename = 'rsc-bundle.js'; + if (discoveryBuild) { + rscConfig.output.filename = 'rsc-reference-discovery.js'; + const RSCReferenceDiscoveryPlugin = rscReferenceDiscoveryPlugin(); + rscConfig.plugins.push(new RSCReferenceDiscoveryPlugin()); + } else { + // Update the output bundle name to be `rsc-bundle.js` instead of `server-bundle.js` + rscConfig.output.filename = 'rsc-bundle.js'; + } return rscConfig; }; diff --git a/react_on_rails/lib/react_on_rails/packs_generator.rb b/react_on_rails/lib/react_on_rails/packs_generator.rb index b8884af5e5..6f25b76ffa 100644 --- a/react_on_rails/lib/react_on_rails/packs_generator.rb +++ b/react_on_rails/lib/react_on_rails/packs_generator.rb @@ -68,9 +68,14 @@ def generated_files_present_and_up_to_date? server_bundle_ready = ReactOnRails.configuration.server_bundle_js_file.blank? || File.exist?(generated_server_bundle_file_path) + server_component_registration_entry_ready = + !ReactOnRails::Utils.rsc_support_enabled? || + server_component_registration_entries.empty? || + File.exist?(server_component_registration_entry_file_path) Dir.exist?(generated_packs_directory_path) && server_bundle_ready && + server_component_registration_entry_ready && !stale_or_missing_packs? end @@ -131,6 +136,7 @@ def generate_packs(verbose: false) store_to_path.each_value { |store_path| create_store_pack(store_path, verbose: verbose) } create_server_pack(verbose: verbose) if ReactOnRails.configuration.server_bundle_js_file.present? + create_server_component_registration_entry(verbose: verbose) if ReactOnRails::Utils.rsc_support_enabled? log_rsc_classification_summary if ReactOnRails::Utils.rsc_support_enabled? end @@ -294,12 +300,26 @@ def store_pack_file_contents(file_path) end def create_server_pack(verbose: false) + ensure_nonentrypoints_directory! unless ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint File.write(generated_server_bundle_file_path, generated_server_pack_file_content) add_generated_pack_to_server_bundle puts(Rainbow("Generated Server Bundle: #{generated_server_bundle_file_path}").orange) if verbose end + def create_server_component_registration_entry(verbose: false) + entries = server_component_registration_entries + return if entries.empty? + + ensure_nonentrypoints_directory! + File.write(server_component_registration_entry_file_path, server_component_registration_entry_content(entries)) + return unless verbose + + puts( + Rainbow("Generated Server Component Entry: #{server_component_registration_entry_file_path}").orange + ) + end + def build_server_pack_content(component_on_server_imports, server_components, client_components, store_imports: [], store_names: []) all_imports = component_on_server_imports + store_imports @@ -323,21 +343,14 @@ def build_server_pack_content(component_on_server_imports, server_components, cl content end - def generated_server_pack_file_content - common_components_for_server_bundle = common_component_to_path.delete_if { |k| server_component_to_path.key?(k) } - component_for_server_registration_to_path = common_components_for_server_bundle.merge(server_component_to_path) + def generated_server_pack_file_content(component_for_server_registration_to_path = nil) + component_for_server_registration_to_path ||= components_for_server_registration component_on_server_imports = component_for_server_registration_to_path.map do |name, component_path| "import #{name} from '#{relative_path(generated_server_bundle_file_path, component_path)}';" end - load_server_components = ReactOnRails::Utils.rsc_support_enabled? - server_components = component_for_server_registration_to_path.keys.delete_if do |name| - next true unless load_server_components - - component_path = component_for_server_registration_to_path[name] - client_entrypoint?(component_path) - end + server_components = server_component_names_for_registration(component_for_server_registration_to_path) client_components = component_for_server_registration_to_path.keys - server_components # Include stores in server bundle @@ -351,6 +364,46 @@ def generated_server_pack_file_content store_imports: store_imports, store_names: store_names) end + def components_for_server_registration + common_components_for_server_bundle = common_component_to_path.reject do |name, _| + server_component_to_path.key?(name) + end + common_components_for_server_bundle.merge(server_component_to_path) + end + + # Accepts an already-fetched component map so callers that have one don't trigger a second + # components_for_server_registration scan (its Dir.glob is un-memoized). + def server_component_names_for_registration(components = nil) + return [] unless ReactOnRails::Utils.rsc_support_enabled? + + components ||= components_for_server_registration + components.keys.reject { |name| client_entrypoint?(components[name]) } + end + + def server_component_registration_entries(components = nil) + return {} unless ReactOnRails::Utils.rsc_support_enabled? + + # Compute the component map once and reuse it: passing it to + # server_component_names_for_registration avoids a second components_for_server_registration + # scan, and this method runs on the dev-server staleness check on each webpack compile. + components ||= components_for_server_registration + components.slice(*server_component_names_for_registration(components)) + end + + def server_component_registration_entry_content(entries = nil) + entries ||= server_component_registration_entries + imports = entries.map do |name, component_path| + "import #{name} from '#{relative_path(server_component_registration_entry_file_path, component_path)}';" + end + + <<~FILE_CONTENT + #{imports.join("\n")} + + import registerServerComponent from '#{react_on_rails_npm_package}/registerServerComponent/server'; + registerServerComponent({ #{entries.keys.join(', ')} }); + FILE_CONTENT + end + def add_generated_pack_to_server_bundle return if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint return if ReactOnRails.configuration.server_bundle_js_file.blank? @@ -372,20 +425,48 @@ def add_generated_pack_to_server_bundle def generated_server_bundle_file_path return server_bundle_entrypoint if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint + "#{generated_nonentrypoints_directory_path}/#{generated_server_bundle_file_name}.js" + end + + def generated_server_bundle_file_name entrypoint_ext = File.extname(server_bundle_entrypoint) generated_interim_server_bundle_path = server_bundle_entrypoint.sub( /#{Regexp.escape(entrypoint_ext)}$/, "-generated#{entrypoint_ext}" ) - generated_server_bundle_file_name = component_name(generated_interim_server_bundle_path) + component_name(generated_interim_server_bundle_path) + end + + def server_component_registration_entry_file_path + "#{generated_nonentrypoints_directory_path}/server-component-registration-entry.js" + end + + def generated_nonentrypoints_directory_path source_entrypoint_parent = Pathname(ReactOnRails::PackerUtils.packer_source_entry_path).parent - generated_nonentrypoints_path = "#{source_entrypoint_parent}/generated" + "#{source_entrypoint_parent}/generated" + end + + # Creates the generated nonentrypoints directory. Kept separate from + # `generated_nonentrypoints_directory_path` so that read-only callers (staleness + # checks, cleanup enumeration in `build_expected_files_set`) can compute the path + # without the side effect of creating the directory. Call this only before writing + # a file into that directory. + def ensure_nonentrypoints_directory! + FileUtils.mkdir_p(generated_nonentrypoints_directory_path) + end - FileUtils.mkdir_p(generated_nonentrypoints_path) - "#{generated_nonentrypoints_path}/#{generated_server_bundle_file_name}.js" + # The server-component registration entry lives in the nonentrypoints `generated/` directory. + # That equals generated_server_bundle_directory_path in the default mode, but when + # make_generated_server_bundle_the_entrypoint is true the server bundle path is the entrypoint + # and generated_server_bundle_directory_path is nil — so the nonentrypoints directory would + # otherwise never be scanned and a stale registration entry could never be cleaned. Add it + # explicitly (only when RSC is enabled, to avoid creating an empty directory otherwise). + def directories_to_clean + directories = [generated_packs_directory_path, generated_server_bundle_directory_path] + directories << generated_nonentrypoints_directory_path if ReactOnRails::Utils.rsc_support_enabled? + directories.compact.uniq end def clean_non_generated_files_with_feedback(verbose: false) - directories_to_clean = [generated_packs_directory_path, generated_server_bundle_directory_path].compact.uniq expected_files = build_expected_files_set puts Rainbow("🧹 Cleaning non-generated files...").yellow if verbose @@ -408,8 +489,14 @@ def build_expected_files_set if ReactOnRails.configuration.server_bundle_js_file.present? expected_server_bundle = generated_server_bundle_file_path end + expected_server_component_registration_entry = server_component_registration_entry_file_path if + ReactOnRails::Utils.rsc_support_enabled? && server_component_registration_entries.any? - { pack_files: expected_pack_files, server_bundle: expected_server_bundle } + { + pack_files: expected_pack_files, + server_bundle: expected_server_bundle, + server_component_registration_entry: expected_server_component_registration_entry + } end def clean_unexpected_files_from_directory(dir_path, expected_files, verbose: false) @@ -429,8 +516,13 @@ def clean_unexpected_files_from_directory(dir_path, expected_files, verbose: fal def find_unexpected_files(existing_files, dir_path, expected_files) existing_files.reject do |file| - if dir_path == generated_server_bundle_directory_path - file == expected_files[:server_bundle] + # The server bundle and the registration entry both live in the nonentrypoints `generated/` + # directory (which equals generated_server_bundle_directory_path in the default mode). + if dir_path == generated_nonentrypoints_directory_path + [ + expected_files[:server_bundle], + expected_files[:server_component_registration_entry] + ].compact.include?(file) else expected_files[:pack_files].include?(file) end @@ -460,11 +552,6 @@ def display_cleanup_summary(total_deleted, verbose: false) end def clean_generated_directories_with_feedback(verbose: false) - directories_to_clean = [ - generated_packs_directory_path, - generated_server_bundle_directory_path - ].compact.uniq - puts Rainbow("🧹 Cleaning generated directories...").yellow if verbose total_deleted = directories_to_clean.sum { |dir_path| clean_directory_with_feedback(dir_path, verbose: verbose) } @@ -682,23 +769,34 @@ def stale_or_missing_packs? store_files = store_to_path.values all_source_files = component_files + store_files - return false if all_source_files.empty? + if all_source_files.any? + most_recent_mtime = Utils.find_most_recent_mtime(all_source_files).to_i - most_recent_mtime = Utils.find_most_recent_mtime(all_source_files).to_i + # Check component packs + component_files.each do |file| + return true if generated_component_pack_stale?(file, most_recent_mtime) + end - # Check component packs - component_files.each do |file| - return true if generated_component_pack_stale?(file, most_recent_mtime) + # Check store packs + store_files.each do |file| + return true if generated_store_pack_stale?(file, most_recent_mtime) + end end - # Check store packs - store_files.each do |file| - return true if generated_store_pack_stale?(file, most_recent_mtime) - end + server_registration_components = server_registration_components_for_staleness + return true if generated_server_bundle_stale?(server_registration_components) + return true if server_component_registration_entry_stale?(server_registration_components) false end + def server_registration_components_for_staleness + return nil if ReactOnRails.configuration.server_bundle_js_file.blank? && + !ReactOnRails::Utils.rsc_support_enabled? + + components_for_server_registration + end + def generated_component_pack_stale?(file, most_recent_mtime) path = generated_pack_path(file) return true if !File.exist?(path) || File.mtime(path).to_i < most_recent_mtime @@ -712,6 +810,38 @@ def generated_store_pack_stale?(file, most_recent_mtime) File.read(path) != store_pack_file_contents(file) end + + def generated_server_bundle_stale?(components = nil) + return false if ReactOnRails.configuration.server_bundle_js_file.blank? + + path = generated_server_bundle_file_path + return true unless File.exist?(path) + + components ||= components_for_server_registration + source_files = components.values + store_to_path.values + return true if generated_file_older_than_sources?(path, source_files) + + File.read(path) != generated_server_pack_file_content(components) + end + + def server_component_registration_entry_stale?(components = nil) + return false unless ReactOnRails::Utils.rsc_support_enabled? + + entries = server_component_registration_entries(components) + return false if entries.empty? + + path = server_component_registration_entry_file_path + return true unless File.exist?(path) + return true if generated_file_older_than_sources?(path, entries.values) + + File.read(path) != server_component_registration_entry_content(entries) + end + + def generated_file_older_than_sources?(generated_file, source_files) + return false if source_files.empty? + + File.mtime(generated_file).to_i < Utils.find_most_recent_mtime(source_files).to_i + end end # rubocop:enable Metrics/ClassLength end diff --git a/react_on_rails/spec/dummy/spec/packs_generator_spec.rb b/react_on_rails/spec/dummy/spec/packs_generator_spec.rb index 4dbefb1e44..cfea4cb66e 100644 --- a/react_on_rails/spec/dummy/spec/packs_generator_spec.rb +++ b/react_on_rails/spec/dummy/spec/packs_generator_spec.rb @@ -66,6 +66,26 @@ def self.configuration end end + context "when computing the nonentrypoints directory path" do + it "is a pure accessor that does not create the directory as a side effect" do + generator = described_class.instance + nonentrypoints_dir = generator.send(:generated_nonentrypoints_directory_path) + FileUtils.rm_rf(nonentrypoints_dir) + + # The path accessor must not touch the filesystem; the mkdir lives in + # ensure_nonentrypoints_directory!, called only before writes. This locks the behavior so + # read-only callers (staleness checks, cleanup enumeration) never create the directory. + expect(generator.send(:generated_nonentrypoints_directory_path)).to eq(nonentrypoints_dir) + expect(Dir.exist?(nonentrypoints_dir)).to be(false) + + # ensure_nonentrypoints_directory! is the only thing that creates it. + generator.send(:ensure_nonentrypoints_directory!) + expect(Dir.exist?(nonentrypoints_dir)).to be(true) + + FileUtils.rm_rf(nonentrypoints_dir) + end + end + context "when component with common file only" do let(:component_name) { "ComponentWithCommonOnly" } let(:component_pack) { "#{generated_directory}/#{component_name}.js" } @@ -343,6 +363,53 @@ def self.rsc_support_enabled? ENV.delete("REACT_ON_RAILS_VERBOSE") end + it "regenerates server artifacts when a server-only component source is newer" do + described_class.instance.generate_packs_if_stale + generator = described_class.instance + server_component_source = + "#{packer_source_path}/components/#{components_directory}/ror_components/" \ + "ReactServerComponentWithClientAndServer.server.jsx" + registration_entry = generator.send(:server_component_registration_entry_file_path) + original_source_mtime = File.mtime(server_component_source) + + stale_generated_time = Time.now - 60 + fresh_source_time = Time.now - 30 + FileUtils.touch(generated_server_bundle_file_path, mtime: stale_generated_time) + FileUtils.touch(registration_entry, mtime: stale_generated_time) + FileUtils.touch(server_component_source, mtime: fresh_source_time) + + ENV["REACT_ON_RAILS_VERBOSE"] = "true" + expect do + described_class.instance.generate_packs_if_stale + end.to output(GENERATED_PACKS_CONSOLE_OUTPUT_REGEX).to_stdout + + expect(File.mtime(generated_server_bundle_file_path)).to be > fresh_source_time + expect(File.mtime(registration_entry)).to be > fresh_source_time + ensure + FileUtils.touch(server_component_source, mtime: original_source_mtime) if original_source_mtime + ENV.delete("REACT_ON_RAILS_VERBOSE") + end + + it "regenerates the registration entry when its content is stale but its mtime is current" do + described_class.instance.generate_packs_if_stale + generator = described_class.instance + registration_entry = generator.send(:server_component_registration_entry_file_path) + expect(File.exist?(registration_entry)).to be(true) + + # A fresh mtime keeps generated_file_older_than_sources? false, so this exercises the + # content-equality branch of server_component_registration_entry_stale? specifically + # (the mtime branch is covered by the preceding example). This is the branch that catches an + # added/removed/renamed server component when no source mtime happens to be newer. + File.write(registration_entry, "// stale registration entry\n") + fresh_mtime = Time.now + 60 + File.utime(fresh_mtime, fresh_mtime, registration_entry) + + described_class.instance.generate_packs_if_stale + + expect(File.read(registration_entry)).not_to include("stale registration entry") + expect(File.read(registration_entry)).to include("registerServerComponent") + end + it "checks generated pack contents without emitting likely-client warnings" do described_class.instance.generate_packs_if_stale component_name = "ReactServerComponent" @@ -370,6 +437,17 @@ def self.rsc_support_enabled? File.write(component_source, original_source) if original_source end + it "does not write a registration entry when there are no server components to register" do + generator = described_class.instance + allow(generator).to receive(:server_component_registration_entries).and_return({}) + entry_path = generator.send(:server_component_registration_entry_file_path) + FileUtils.rm_f(entry_path) + + generator.send(:create_server_component_registration_entry) + + expect(File.exist?(entry_path)).to be(false) + end + context "when RSC support is disabled" do before do allow(ReactOnRailsPro::Utils).to receive(:rsc_support_enabled?).and_return(false) @@ -433,6 +511,216 @@ def self.rsc_support_enabled? expect(generated_server_bundle_content.strip).to eq(expected_content.strip) end + + it "classifies server and client components without rescanning component paths" do + generator = described_class.new + components = { + "ReactClientComponent" => + "#{packer_source_path}/components/ReactServerComponents/ror_components/ReactClientComponent.jsx", + "ReactServerComponent" => + "#{packer_source_path}/components/ReactServerComponents/ror_components/ReactServerComponent.jsx" + } + + # Guard the no-rescan optimization: generated_server_pack_file_content must fetch the + # component map exactly once (components_for_server_registration runs an un-memoized + # Dir.glob). `expect ... .once` verifies the count; `allow ... .once` would not. + expect(generator).to receive(:components_for_server_registration).once.and_return(components) + generated_server_bundle_path = File.join( + Pathname(packer_source_entry_path).parent, + "generated/server-bundle-generated.js" + ) + allow(generator).to receive_messages( + store_to_path: {}, + generated_server_bundle_file_path: generated_server_bundle_path + ) + allow(generator).to receive(:client_entrypoint?) do |path| + path.end_with?("ReactClientComponent.jsx") + end + + generated_server_bundle_content = generator.send(:generated_server_pack_file_content) + + expect(generated_server_bundle_content).to include("registerServerComponent({ReactServerComponent});") + expect(generated_server_bundle_content).to include("ReactOnRails.register({ReactClientComponent});") + end + + it "reuses precomputed components when checking generated server bundle freshness" do + generator = described_class.new + components = { + "ReactServerComponent" => + "#{packer_source_path}/components/ReactServerComponents/ror_components/ReactServerComponent.jsx" + } + generated_server_bundle_path = File.join( + Pathname(packer_source_entry_path).parent, + "generated/server-bundle-generated.js" + ) + + allow(generator).to receive(:components_for_server_registration).and_return(components) + allow(generator).to receive_messages( + generated_server_bundle_file_path: generated_server_bundle_path, + store_to_path: {} + ) + allow(generator).to receive(:generated_file_older_than_sources?) + .with(generated_server_bundle_path, components.values) + .and_return(false) + allow(generator).to receive(:generated_server_pack_file_content).with(components).and_return("fresh") + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(generated_server_bundle_path).and_return(true) + allow(File).to receive(:read).with(generated_server_bundle_path).and_return("fresh") + + expect(generator.send(:generated_server_bundle_stale?)).to be(false) + expect(generator).to have_received(:components_for_server_registration).once + expect(generator).to have_received(:generated_server_pack_file_content).with(components) + end + + it "reuses precomputed entries when writing and checking the RSC registration entry" do + generator = described_class.new + entries = { + "ReactServerComponent" => + "#{packer_source_path}/components/ReactServerComponents/ror_components/ReactServerComponent.jsx" + } + registration_entry_path = File.join( + Pathname(packer_source_entry_path).parent, + "generated/server-component-registration-entry.js" + ) + + allow(generator).to receive_messages( + server_component_registration_entries: entries, + server_component_registration_entry_file_path: registration_entry_path + ) + allow(generator).to receive(:server_component_registration_entry_content) + .with(entries) + .and_return("fresh") + allow(generator).to receive(:ensure_nonentrypoints_directory!) + expect(File).to receive(:write).with(registration_entry_path, "fresh") + + generator.send(:create_server_component_registration_entry) + + allow(generator).to receive(:generated_file_older_than_sources?) + .with(registration_entry_path, entries.values) + .and_return(false) + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(registration_entry_path).and_return(true) + allow(File).to receive(:read).with(registration_entry_path).and_return("fresh") + + expect(generator.send(:server_component_registration_entry_stale?)).to be(false) + expect(generator).to have_received(:server_component_registration_entries).twice + expect(generator).to have_received(:server_component_registration_entry_content).with(entries).twice + end + + it "reuses server registration components across the staleness fast path" do + generator = described_class.new + components = { + "ReactServerComponent" => + "#{packer_source_path}/components/ReactServerComponents/ror_components/ReactServerComponent.jsx" + } + generated_server_bundle_path = File.join( + Pathname(packer_source_entry_path).parent, + "generated/server-bundle-generated.js" + ) + registration_entry_path = File.join( + Pathname(packer_source_entry_path).parent, + "generated/server-component-registration-entry.js" + ) + + allow(generator).to receive_messages( + common_component_to_path: {}, + client_component_to_path: {}, + store_to_path: {}, + generated_server_bundle_file_path: generated_server_bundle_path, + server_component_registration_entry_file_path: registration_entry_path, + client_entrypoint?: false + ) + allow(generator).to receive(:components_for_server_registration).and_return(components) + allow(generator).to receive(:generated_file_older_than_sources?) + .with(generated_server_bundle_path, components.values) + .and_return(false) + allow(generator).to receive(:generated_file_older_than_sources?) + .with(registration_entry_path, components.values) + .and_return(false) + allow(generator).to receive(:generated_server_pack_file_content).with(components).and_return("fresh") + allow(generator) + .to receive(:server_component_registration_entry_content) + .with(components) + .and_return("fresh") + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(generated_server_bundle_path).and_return(true) + allow(File).to receive(:exist?).with(registration_entry_path).and_return(true) + allow(File).to receive(:read).with(generated_server_bundle_path).and_return("fresh") + allow(File).to receive(:read).with(registration_entry_path).and_return("fresh") + + expect(generator.send(:stale_or_missing_packs?)).to be(false) + expect(generator).to have_received(:components_for_server_registration).once + end + + it "creates a server component registration entry for RSC reference discovery" do + generated_entry_path = File.join( + Pathname(packer_source_entry_path).parent, + "generated/server-component-registration-entry.js" + ) + generated_entry_content = File.read(generated_entry_path) + expected_content = <<~CONTENT.strip + import ReactServerComponent from '../components/ReactServerComponents/ror_components/ReactServerComponent.jsx'; + import ReactServerComponentWithClientAndServer from '../components/ReactServerComponents/ror_components/ReactServerComponentWithClientAndServer.server.jsx'; + + import registerServerComponent from 'react-on-rails-pro/registerServerComponent/server'; + registerServerComponent({ ReactServerComponent, ReactServerComponentWithClientAndServer }); + CONTENT + + expect(generated_entry_content.strip).to eq(expected_content.strip) + expect(generated_entry_content).not_to include("ReactOnRails.register") + expect(generated_entry_content).not_to include("ReactClientComponent") + end + + it "preserves the registration entry while removing stray files during cleanup" do + generated_dir = File.join(Pathname(packer_source_entry_path).parent, "generated") + entry_path = File.join(generated_dir, "server-component-registration-entry.js") + stray_path = File.join(generated_dir, "stray-orphan.js") + File.write(stray_path, "// stray\n") + expect(File.exist?(entry_path)).to be(true) + + described_class.instance.generate_packs_if_stale + + expect(File.exist?(stray_path)).to be(false) + expect(File.exist?(entry_path)).to be(true) + end + + it "regenerates the registration entry when only it is missing" do + entry_path = File.join( + Pathname(packer_source_entry_path).parent, + "generated/server-component-registration-entry.js" + ) + expect(File.exist?(entry_path)).to be(true) + File.delete(entry_path) + + described_class.instance.generate_packs_if_stale + + expect(File.exist?(entry_path)).to be(true) + end + + it "scans the nonentrypoints directory for cleanup even when the server bundle is the entrypoint" do + generator = described_class.instance + ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint = true + nonentrypoints_dir = generator.send(:generated_nonentrypoints_directory_path) + + # generated_server_bundle_directory_path is nil in entrypoint mode, so without the explicit + # add the registration entry's directory would never be enumerated for cleanup. + expect(generator.send(:directories_to_clean)).to include(nonentrypoints_dir) + ensure + ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint = false + end + + it "treats the registration entry as expected and stray files as unexpected" do + generator = described_class.instance + nonentrypoints_dir = generator.send(:generated_nonentrypoints_directory_path) + entry = generator.send(:server_component_registration_entry_file_path) + stray = File.join(nonentrypoints_dir, "stray-orphan.js") + expected_files = generator.send(:build_expected_files_set) + + unexpected = generator.send(:find_unexpected_files, [entry, stray], nonentrypoints_dir, expected_files) + + expect(unexpected).to include(stray) + expect(unexpected).not_to include(entry) + end end end diff --git a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb index 8463c29e35..3b59fd4884 100644 --- a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb @@ -4096,6 +4096,27 @@ class ActiveSupport::TestCase end end + it "emits the RSC manifest discovery logic in bin/shakapacker-precompile-hook" do + # The shipped template hook is a separate implementation from spec/support's standalone copy; + # pin its load-bearing pieces so an accidental edit/deletion fails here rather than silently in + # a user's RSC build. The discovery build re-invokes bin/shakapacker, so the recursion guard + # and the rsc_support_enabled? gate are critical. + Dir.chdir(destination_root) do + install_generator.send(:add_bin_scripts) + end + + assert_file "bin/shakapacker-precompile-hook" do |content| + expect(content).to include("generate_rsc_manifest_client_references_if_needed") + expect(content).to include('ENV["RSC_REFERENCE_DISCOVERY_BUILD"] == "true"') + expect(content).to include("ReactOnRailsPro::Utils.rsc_support_enabled?") + expect(content).to include('"RSC_BUNDLE_ONLY" => "true"') + expect(content).to include('"CLIENT_BUNDLE_ONLY" => nil') + expect(content).to include('"SERVER_BUNDLE_ONLY" => nil') + expect(content).to include("Dir.chdir(Rails.root) do") + expect(content).to include("system(env, shakapacker_bin.to_s, exception: true)") + end + end + it "detects the legacy Rails foreman bin/dev template" do simulate_existing_file("bin/dev", <<~BASH) #!/usr/bin/env bash @@ -4164,7 +4185,13 @@ class ActiveSupport::TestCase assert_file "bin/dev", custom_bin_dev assert_file "bin/switch-bundler" - assert_file "bin/shakapacker-precompile-hook" + assert_file "bin/shakapacker-precompile-hook" do |content| + expect(content).to include('stale_manifest = Rails.root.join("ssr-generated", "rsc-client-references.json")') + expect(content).to include("clear_stale_rsc_manifest_client_references") + expect(content).to include('shakapacker_bin = Rails.root.join("bin", "shakapacker")') + expect(content).to include("bin/shakapacker is missing; cannot generate RSC manifest client references.") + expect(content).to include("system(env, shakapacker_bin.to_s, exception: true)") + end end it "keeps DEFAULT_ROUTE unchanged in custom bin/dev files for non-RSC installs" do diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index b28745fee3..b1fdd12f1f 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -658,24 +658,26 @@ def errors describe "#add_rsc_dependencies" do it "installs version-pinned rsc dependency" do - allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@19.0.5-rc.6"], true]) + rsc_package = "react-on-rails-rsc@#{ReactOnRails::Generators::JsDependencyManager::RSC_PACKAGE_VERSION_PIN}" + allow(instance).to receive(:rsc_packages_with_version).and_return([[rsc_package], true]) instance.send(:add_rsc_dependencies) expect(instance.add_npm_dependencies_calls).to include( - a_hash_including(packages: ["react-on-rails-rsc@19.0.5-rc.6"], dev: false) + a_hash_including(packages: [rsc_package], dev: false) ) end it "falls back to unversioned package when pinned install fails" do - allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@19.0.5-rc.6"], true]) + rsc_package = "react-on-rails-rsc@#{ReactOnRails::Generators::JsDependencyManager::RSC_PACKAGE_VERSION_PIN}" + allow(instance).to receive(:rsc_packages_with_version).and_return([[rsc_package], true]) - allow(instance).to receive(:add_packages).with(["react-on-rails-rsc@19.0.5-rc.6"]).and_return(false) + allow(instance).to receive(:add_packages).with([rsc_package]).and_return(false) allow(instance).to receive(:add_packages).with(["react-on-rails-rsc"]).and_return(true) instance.send(:add_rsc_dependencies) - expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc@19.0.5-rc.6"]) + expect(instance).to have_received(:add_packages).with([rsc_package]) expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc"]) expect(warnings.join("\n")).to include("installed react-on-rails-rsc version may not match") end diff --git a/react_on_rails/spec/react_on_rails/generators/rsc_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/rsc_generator_spec.rb index 16a91a5a93..5fa636497d 100644 --- a/react_on_rails/spec/react_on_rails/generators/rsc_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/rsc_generator_spec.rb @@ -62,6 +62,12 @@ it "creates RSC webpack config" do assert_file "config/webpack/rscWebpackConfig.js" do |content| expect(content).to include("rscConfig") + expect(content).not_to include( + "const { RSCReferenceDiscoveryPlugin } = require('react-on-rails-rsc/RSCReferenceDiscoveryPlugin');" + ) + expect(content).to include("const serverWebpackModule = require('./serverWebpackConfig');") + expect(content).to include("require('react-on-rails-rsc/RSCReferenceDiscoveryPlugin')") + expect(content).to include("Run bin/shakapacker-precompile-hook before bin/shakapacker.") end end @@ -1159,7 +1165,7 @@ ) end - it "does not duplicate an existing scoped rscClientReferences helper on the fresh-install path" do + it "upgrades an existing broad rscClientReferences helper on the fresh-install path" do config_path = "config/webpack/clientWebpackConfig.js" simulate_existing_file( config_path, @@ -1188,6 +1194,8 @@ migrated_content = File.read(File.join(destination_root, config_path)) expect(migrated_content.scan("const rscClientReferences").length).to eq(1) + expect(migrated_content).to include("const fallbackRscClientReferences = {") + expect(migrated_content).to include("const rscClientReferences = (() => {") expect(migrated_content).to include( "const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');" ) @@ -1196,6 +1204,48 @@ ) end + it "upgrades an existing broad helper when the plugin already references rscClientReferences" do + config_path = "config/webpack/clientWebpackConfig.js" + simulate_existing_file( + config_path, + <<~JS + const { config } = require('shakapacker'); + const { resolve } = require('path'); + const commonWebpackConfig = require('./commonWebpackConfig'); + const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin'); + + const rscClientReferences = { + directory: resolve(config.source_path), + recursive: true, + include: /\\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/, + }; + + const configureClient = () => { + const clientConfig = commonWebpackConfig(); + clientConfig.plugins.push( + new RSCWebpackPlugin({ + isServer: false, + clientReferences: rscClientReferences, + }), + ); + + return clientConfig; + }; + + module.exports = configureClient; + JS + ) + + content = File.read(File.join(destination_root, config_path)) + generator.send(:update_existing_rsc_webpack_config, config_path, content, is_server: false) + + migrated_content = File.read(File.join(destination_root, config_path)) + expect(migrated_content.scan("const rscClientReferences").length).to eq(1) + expect(migrated_content).to include("const fallbackRscClientReferences = {") + expect(migrated_content).to include("const rscClientReferences = (() => {") + expect(migrated_content).to include("clientReferences: rscClientReferences") + end + it "warns and skips wiring an existing unscoped rscClientReferences helper on the fresh-install path" do config_path = "config/webpack/clientWebpackConfig.js" simulate_existing_file( @@ -1590,7 +1640,7 @@ expect(generator.send(:rsc_plugin_uses_scoped_client_references?, content, is_server: false)).to be(false) end - it "treats a server plugin with scoped client references as already migrated" do + it "does not treat a legacy broad helper as already manifest-backed" do content = <<~JS const { config } = require('shakapacker'); const { resolve } = require('path'); @@ -1607,6 +1657,30 @@ ); JS + expect(generator.send(:rsc_plugin_uses_scoped_client_references?, content, is_server: true)).to be(false) + expect(generator.send(:scoped_rsc_client_references_defined?, content)).to be(true) + end + + it "treats a server plugin with manifest-backed client references as already migrated" do + content = <<~JS + const { config } = require('shakapacker'); + const { resolve } = require('path'); + const fallbackRscClientReferences = { + directory: resolve(config.source_path), + recursive: true, + include: /\\.(js|ts|jsx|tsx)$/, + }; + const rscClientReferences = (() => { + return [fallbackRscClientReferences]; + })(); + serverWebpackConfig.plugins.push( + new RSCWebpackPlugin({ + isServer: true, + clientReferences: rscClientReferences, + }), + ); + JS + expect(generator.send(:rsc_plugin_uses_scoped_client_references?, content, is_server: true)).to be(true) end @@ -1647,6 +1721,61 @@ expect(generator.send(:scoped_rsc_client_references_defined?, content)).to be(false) end + it "detects generated manifest client references with a scoped fallback" do + content = <<~JS + const fallbackRscClientReferences = { + directory: resolve(config.source_path), + recursive: true, + include: /.(js|ts|jsx|tsx)$/, + }; + + const rscClientReferences = (() => { + const configuredRefsJson = process.env.RSC_MANIFEST_CLIENT_REFERENCES_JSON; + const refsJson = configuredRefsJson || resolve('ssr-generated/rsc-client-references.json'); + return fallbackRscClientReferences; + })(); + JS + + expect(generator.send(:rsc_client_references_defined?, content)).to be(true) + expect(generator.send(:scoped_rsc_client_references_defined?, content)).to be(true) + end + + it "detects the generated manifest fallback even when the env-var/JSON marker strings change" do + # Detection is structural (the module-scope `fallbackRscClientReferences` literal), not + # coupled to the `RSC_MANIFEST_CLIENT_REFERENCES_JSON` / `rsc-client-references.json` + # template strings. Renaming those internals must not silently break re-run detection. + content = <<~JS + const fallbackRscClientReferences = { + directory: resolve(config.source_path), + recursive: true, + include: /.(js|ts|jsx|tsx)$/, + }; + + const rscClientReferences = (() => { + const configuredRefsJson = process.env.SOME_RENAMED_ENV_VAR; + const refsJson = configuredRefsJson || resolve('ssr-generated/renamed-refs.json'); + return fallbackRscClientReferences; + })(); + JS + + expect(generator.send(:scoped_rsc_client_references_defined?, content)).to be(true) + end + + it "does not detect a fully commented-out generated manifest block" do + content = <<~JS + // const fallbackRscClientReferences = { + // directory: resolve(config.source_path), + // }; + // + // const rscClientReferences = (() => { + // return fallbackRscClientReferences; + // })(); + JS + + expect(generator.send(:rsc_client_references_defined?, content)).to be(false) + expect(generator.send(:scoped_rsc_client_references_defined?, content)).to be(false) + end + it "detects a module-scope let rscClientReferences declaration" do content = <<~JS let rscClientReferences = { @@ -1864,7 +1993,8 @@ generator.send(:update_existing_rsc_webpack_config, config_path, content, is_server: false) migrated_content = File.binread(File.join(destination_root, config_path)) - expect(migrated_content).to include("const rscClientReferences = {\r\n") + expect(migrated_content).to include("const fallbackRscClientReferences = {\r\n") + expect(migrated_content).to include("const rscClientReferences = (() => {\r\n") expect(migrated_content).to include(" isServer: false,\r\n clientReferences: rscClientReferences,") expect(migrated_content).not_to match(/(? {") expect(content).to include("directory: resolve(config.source_path)") expect(content).to include("clientReferences: rscClientReferences") end @@ -3124,6 +3255,54 @@ expect(GeneratorMessages.messages.join("\n")) .not_to include("all matching RSCWebpackPlugin instances already define clientReferences") end + + it "emits the manifest resolution contract that the Pro dummy mirrors" do + # Keep these tokens in lockstep with the Pro dummy's hand-written mirror at + # react_on_rails_pro/spec/dummy/config/webpack/rscManifestClientReferences.js (pinned by + # react_on_rails_pro/spec/dummy/tests/rsc-manifest-client-references.test.js). Drift on either + # side fails CI. + assert_file "config/webpack/clientWebpackConfig.js" do |content| + expect(content).to include("process.env.RSC_MANIFEST_CLIENT_REFERENCES_JSON") + expect(content).to include("ssr-generated/rsc-client-references.json") + expect(content).to include("RSC_REFERENCE_DISCOVERY_BUILD") + expect(content).to include("RSC_BUNDLE_ONLY") + expect(content).to include("Run bin/shakapacker-precompile-hook before bin/shakapacker.") + expect(content).to include("rscConfigSupportsDiscovery") + expect(content).to include("config/webpack/rscWebpackConfig.js") + expect(content).to include("bin/shakapacker-precompile-hook") + expect(content).to include("const content = readFileSync(filePath, 'utf8');") + expect(content).to include("falling back to broad client") + expect(content).to include("reference scan. Re-run rails g react_on_rails:rsc") + expect(content).to include("Array.isArray(payload.refs)") + expect(content).to include("to contain a refs array") + # The configured override is path-resolved on both sides (mirror parity). + expect(content).to include("resolve(configuredRefsJson)") + # A configured override that does not exist throws a clear error (mirror parity). + expect(content).to include("RSC_MANIFEST_CLIENT_REFERENCES_JSON is set but the file does not exist") + # Malformed manifest JSON is re-thrown with the file path (mirror parity). + expect(content).to include("Failed to parse RSC client references manifest") + # Configured overrides also get the best-effort staleness warning (mirror parity). + expect(content).to include("warnIfManifestStale(resolvedRefsJson)") + configured_refs_index = content.index("if (configuredRefsJson)") + discovery_build_index = content.index("if (process.env.RSC_REFERENCE_DISCOVERY_BUILD") + default_refs_index = content.index("if (existsSync(defaultRefsJson))") + expect(configured_refs_index).not_to be_nil + expect(discovery_build_index).not_to be_nil + expect(default_refs_index).not_to be_nil + expect(configured_refs_index).to be < discovery_build_index + expect(discovery_build_index).to be < default_refs_index + # Best-effort staleness warning: manifest older than the registration entry -> console.warn. + expect(content).to include("statSync") + expect(content).to include("catch {") + expect(content).to include("may be stale") + # The fallback resolves to an array, exactly like the manifest path (payload.refs) and the + # Pro dummy mirror's DEFAULT_CLIENT_REFERENCES, so clientReferences always receives an array + # regardless of which branch the cascade returns from (mirror parity). + expect(content.scan("return [fallbackRscClientReferences];").length).to eq(3) + # Pin the include extension set byte-for-byte in lockstep with the Pro dummy mirror. + expect(content).to include("/\\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/") + end + end end context "when an existing client RSC webpack config mixes custom and scoped client references" do @@ -3175,7 +3354,8 @@ it "injects the missing scoped helper without touching the custom clientReferences" do assert_file "config/webpack/clientWebpackConfig.js" do |content| - expect(content).to include("const rscClientReferences = {") + expect(content).to include("const fallbackRscClientReferences = {") + expect(content).to include("const rscClientReferences = (() => {") expect(content).to include("directory: resolve(config.source_path)") expect(content.scan("clientReferences: customClientReferences").length).to eq(1) expect(content.scan("clientReferences: rscClientReferences").length).to eq(1) diff --git a/react_on_rails/spec/react_on_rails/shakapacker_precompile_hook_shared_spec.rb b/react_on_rails/spec/react_on_rails/shakapacker_precompile_hook_shared_spec.rb index d9e5ea9cd9..6cdd595595 100644 --- a/react_on_rails/spec/react_on_rails/shakapacker_precompile_hook_shared_spec.rb +++ b/react_on_rails/spec/react_on_rails/shakapacker_precompile_hook_shared_spec.rb @@ -1,19 +1,221 @@ # frozen_string_literal: true require_relative "spec_helper" +require "tmpdir" RSpec.describe "Shakapacker precompile hook shared script" do before do load File.expand_path("../support/shakapacker_precompile_hook_shared.rb", __dir__) end + def with_env(overrides) + # Initialize outside the begin/ensure region so the cleanup always has a hash to iterate, + # even if an ENV operation in the protected body raises. + original = {} + begin + overrides.each_key { |key| original[key] = ENV.fetch(key, nil) } + overrides.each { |key, value| ENV[key] = value } + yield + ensure + original.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + end + + it "preserves setup failures while cleaning any captured env keys" do + bad_overrides = Object.new + + def bad_overrides.each_key + raise "env setup failed" + end + + expect { with_env(bad_overrides) { raise "should not yield" } }.to raise_error(RuntimeError, "env setup failed") + end + it "exposes run_precompile_tasks for load-based callers" do allow(self).to receive(:build_rescript_if_needed) allow(self).to receive(:generate_packs_if_needed) + allow(self).to receive(:generate_rsc_manifest_client_references_if_needed) run_precompile_tasks expect(self).to have_received(:build_rescript_if_needed) expect(self).to have_received(:generate_packs_if_needed) + expect(self).to have_received(:generate_rsc_manifest_client_references_if_needed) + end + + describe "valid_rsc_registration_entry_path?" do + it "rejects registration entries under generated dependency, output, temp, or test trees" do + expect(valid_rsc_registration_entry_path?( + "/app/node_modules/pkg/client/generated/server-component-registration-entry.js" + )).to be(false) + expect(valid_rsc_registration_entry_path?( + "/app/public/packs/generated/server-component-registration-entry.js" + )).to be(false) + expect(valid_rsc_registration_entry_path?( + "/app/spec/fixtures/generated/server-component-registration-entry.js" + )).to be(false) + expect(valid_rsc_registration_entry_path?( + "/app/test/fixtures/generated/server-component-registration-entry.js" + )).to be(false) + expect(valid_rsc_registration_entry_path?( + "/app/tmp/cache/generated/server-component-registration-entry.js" + )).to be(false) + expect(valid_rsc_registration_entry_path?( + "/app/.git/objects/generated/server-component-registration-entry.js" + )).to be(false) + expect(valid_rsc_registration_entry_path?( + "/app/vendor/bundle/generated/server-component-registration-entry.js" + )).to be(false) + expect(valid_rsc_registration_entry_path?( + "/app/log/generated/server-component-registration-entry.js" + )).to be(false) + end + + it "accepts a registration entry under the app source tree" do + expect(valid_rsc_registration_entry_path?( + "/app/client/app/generated/server-component-registration-entry.js" + )).to be(true) + end + + it "treats excluded names as path components instead of substrings" do + expect(valid_rsc_registration_entry_path?( + "/app/client/tmpfiles/generated/server-component-registration-entry.js" + )).to be(true) + end + end + + describe "rsc_manifest_registration_entry" do + it "finds app entries without traversing excluded directory trees" do + Dir.mktmpdir(nil, "/tmp") do |rails_root| + app_entry = File.join(rails_root, "client", "app", "generated", "server-component-registration-entry.js") + node_modules_entry = File.join( + rails_root, + "node_modules", + "pkg", + "generated", + "server-component-registration-entry.js" + ) + FileUtils.mkdir_p(File.dirname(app_entry)) + FileUtils.mkdir_p(File.dirname(node_modules_entry)) + File.write(app_entry, "// app\n") + File.write(node_modules_entry, "// ignored\n") + + expect(rsc_manifest_registration_entry(rails_root)).to eq(app_entry) + end + end + + it "ignores generated registration fixtures under spec trees" do + Dir.mktmpdir(nil, "/tmp") do |rails_root| + fixture_entry = File.join( + rails_root, + "spec", + "fixtures", + "automated_packs_generation", + "generated", + "server-component-registration-entry.js" + ) + FileUtils.mkdir_p(File.dirname(fixture_entry)) + File.write(fixture_entry, "// fixture\n") + + expect(rsc_manifest_registration_entry(rails_root)).to be_nil + end + end + end + + describe "generate_rsc_manifest_client_references_if_needed" do + it "skips discovery during a reference-discovery build to avoid recursing into itself" do + allow(self).to receive(:find_rails_root) + + with_env("RSC_REFERENCE_DISCOVERY_BUILD" => "true") do + generate_rsc_manifest_client_references_if_needed + end + + expect(self).not_to have_received(:find_rails_root) + end + + it "removes stale default client references when no server component registration entry is present" do + Dir.mktmpdir(nil, "/tmp") do |rails_root| + stale_manifest = File.join(rails_root, "ssr-generated", "rsc-client-references.json") + FileUtils.mkdir_p(File.dirname(stale_manifest)) + File.write(stale_manifest, "{}\n") + allow(self).to receive_messages(find_rails_root: rails_root, rsc_manifest_registration_entry: nil) + allow(self).to receive(:system) + + with_env("RSC_REFERENCE_DISCOVERY_BUILD" => nil) do + generate_rsc_manifest_client_references_if_needed + end + + expect(self).not_to have_received(:system) + expect(File).not_to exist(stale_manifest) + end + end + + it "aborts when a registration entry exists but the shakapacker binstub is missing" do + registration_entry = "/rails/root/client/app/generated/server-component-registration-entry.js" + allow(self).to receive_messages(find_rails_root: "/rails/root", + rsc_manifest_registration_entry: registration_entry) + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with("/rails/root/bin/shakapacker").and_return(false) + allow(self).to receive(:warn) + allow(self).to receive(:system) + + with_env("RSC_REFERENCE_DISCOVERY_BUILD" => nil) do + expect { generate_rsc_manifest_client_references_if_needed }.to raise_error(SystemExit) + end + + expect(self).not_to have_received(:system) + expect(self).to have_received(:warn).with(%r{bin/shakapacker is missing}) + end + + it "aborts the precompile with a non-zero exit when the discovery build fails" do + registration_entry = "/rails/root/client/app/generated/server-component-registration-entry.js" + allow(self).to receive_messages(find_rails_root: "/rails/root", + rsc_manifest_registration_entry: registration_entry) + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with("/rails/root/bin/shakapacker").and_return(true) + allow(Dir).to receive(:chdir).and_yield + allow(self).to receive(:puts) + allow(self).to receive(:warn) + discovery_error = RuntimeError.new("discovery build failed") + discovery_error.set_backtrace(["bin/shakapacker:12", "config/webpack/rscWebpackConfig.js:4"]) + allow(self).to receive(:system).and_raise(discovery_error) + + with_env("RSC_REFERENCE_DISCOVERY_BUILD" => nil) do + expect { generate_rsc_manifest_client_references_if_needed }.to raise_error(SystemExit) + end + + expect(self).to have_received(:warn).with(/RSC manifest client reference generation failed/) + expect(self).to have_received(:warn).with("bin/shakapacker:12\nconfig/webpack/rscWebpackConfig.js:4") + end + + it "clears client/server bundle-only env vars for the nested discovery build" do + registration_entry = "/rails/root/client/app/generated/server-component-registration-entry.js" + allow(self).to receive_messages(find_rails_root: "/rails/root", + rsc_manifest_registration_entry: registration_entry) + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with("/rails/root/bin/shakapacker").and_return(true) + allow(Dir).to receive(:chdir).with("/rails/root").and_yield + allow(self).to receive(:puts) + allow(self).to receive(:system) + + with_env( + "CLIENT_BUNDLE_ONLY" => "true", + "SERVER_BUNDLE_ONLY" => "true", + "RSC_REFERENCE_DISCOVERY_BUILD" => nil + ) do + generate_rsc_manifest_client_references_if_needed + end + + expect(self).to have_received(:system).with( + hash_including( + "CLIENT_BUNDLE_ONLY" => nil, + "SERVER_BUNDLE_ONLY" => nil, + "RSC_BUNDLE_ONLY" => "true", + "RSC_REFERENCE_DISCOVERY_BUILD" => "true" + ), + "/rails/root/bin/shakapacker", + exception: true + ) + end end end diff --git a/react_on_rails/spec/support/shakapacker_precompile_hook_shared.rb b/react_on_rails/spec/support/shakapacker_precompile_hook_shared.rb index a4f00709b5..0865ba64f1 100755 --- a/react_on_rails/spec/support/shakapacker_precompile_hook_shared.rb +++ b/react_on_rails/spec/support/shakapacker_precompile_hook_shared.rb @@ -14,8 +14,15 @@ # See: https://github.com/shakacode/shakapacker/blob/main/docs/precompile_hook.md require "fileutils" +require "find" require "json" +# Guarded so the specs, which `load` this script once per example, don't warn on constant +# re-initialization (the script is also run directly as the precompile hook). +unless defined?(EXCLUDED_RSC_REGISTRATION_ENTRY_PATH_COMPONENTS) + EXCLUDED_RSC_REGISTRATION_ENTRY_PATH_COMPONENTS = %w[.git log node_modules public spec test tmp vendor].freeze +end + # Find Rails root by walking upward looking for config/environment.rb def find_rails_root dir = Dir.pwd @@ -123,10 +130,104 @@ def generate_packs_if_needed exit 1 end +def rsc_registration_entry_path_components(path, rails_root: nil) + expanded_path = File.expand_path(path) + return [] if rails_root && expanded_path == File.expand_path(rails_root) + + if rails_root + expanded_root = "#{File.expand_path(rails_root)}#{File::SEPARATOR}" + expanded_path = expanded_path.delete_prefix(expanded_root) if expanded_path.start_with?(expanded_root) + end + + expanded_path.split(File::SEPARATOR).reject(&:empty?) +end + +def valid_rsc_registration_entry_path?(path, rails_root: nil) + path_components = rsc_registration_entry_path_components(path, rails_root: rails_root) + EXCLUDED_RSC_REGISTRATION_ENTRY_PATH_COMPONENTS.none? { |component| path_components.include?(component) } +end + +def rsc_manifest_registration_entry(rails_root) + Find.find(rails_root) do |path| + if File.directory?(path) + Find.prune unless valid_rsc_registration_entry_path?(path, rails_root: rails_root) + next + end + + next unless File.basename(path) == "server-component-registration-entry.js" + next unless File.basename(File.dirname(path)) == "generated" + + return path if valid_rsc_registration_entry_path?(path, rails_root: rails_root) + end + + nil +end + +def clear_stale_rsc_manifest_client_references(rails_root) + stale_manifest = File.join(rails_root, "ssr-generated", "rsc-client-references.json") + FileUtils.rm_f(stale_manifest) +end + +# Generate RSC manifest client references if a server component registration entry exists. +# +# Unlike the shipped template hook +# (lib/generators/react_on_rails/templates/base/base/bin/shakapacker-precompile-hook), which loads +# the full Rails environment and gates on `ReactOnRailsPro::Utils.rsc_support_enabled?`, this +# standalone script never requires `config/environment` (it only walks up for the Rails root), so +# ReactOnRailsPro is not loaded and `rsc_support_enabled?` is unavailable here. Instead it relies on +# the registration entry's absence as the capability signal: the entry is written only when RSC is +# enabled AND there is at least one server component to register (see +# PacksGenerator#create_server_component_registration_entry, which returns early when there are no +# server components), so a missing entry means there is nothing to discover (RSC off, or RSC on with +# no server components) and discovery is skipped. The early `RSC_REFERENCE_DISCOVERY_BUILD` guard +# prevents the discovery build (which re-invokes bin/shakapacker) from recursing into itself. +def generate_rsc_manifest_client_references_if_needed + return if ENV["RSC_REFERENCE_DISCOVERY_BUILD"] == "true" + + rails_root = find_rails_root + return unless rails_root + + registration_entry = rsc_manifest_registration_entry(rails_root) + unless registration_entry + clear_stale_rsc_manifest_client_references(rails_root) + return + end + + shakapacker_bin = File.join(rails_root, "bin", "shakapacker") + unless File.exist?(shakapacker_bin) + raise "bin/shakapacker is missing; cannot generate RSC manifest client references. " \ + "Restore bin/shakapacker before precompiling RSC assets." + end + + puts "🔎 Generating RSC manifest client references..." + + env = { + "SHAKAPACKER_SKIP_PRECOMPILE_HOOK" => "true", + "RSC_REFERENCE_DISCOVERY_BUILD" => "true", + "RSC_BUNDLE_ONLY" => "true", + "CLIENT_BUNDLE_ONLY" => nil, + "SERVER_BUNDLE_ONLY" => nil + } + + Dir.chdir(rails_root) do + system(env, shakapacker_bin, exception: true) + puts "✅ RSC manifest client references generated successfully" + end +# The discovered manifest is load-bearing for correct client references, so a failed discovery build +# must abort the precompile (exit 1) rather than warn — matching the template hook, which lets the +# error propagate to its top-level rescue. The shakapacker binary's existence is already asserted +# above, so any failure here (including Errno::ENOENT) is a real error, not a benign "tool missing". +rescue StandardError => e + warn "❌ RSC manifest client reference generation failed: #{e.message}" + warn e.backtrace.first(5).join("\n") if e.backtrace + exit 1 +end + # Main execution (only if run directly, not when required) def run_precompile_tasks build_rescript_if_needed generate_packs_if_needed + generate_rsc_manifest_client_references_if_needed end run_precompile_tasks if __FILE__ == $PROGRAM_NAME diff --git a/react_on_rails_pro/spec/dummy/config/webpack/clientWebpackConfig.js b/react_on_rails_pro/spec/dummy/config/webpack/clientWebpackConfig.js index e4598e9abc..c6698a03aa 100644 --- a/react_on_rails_pro/spec/dummy/config/webpack/clientWebpackConfig.js +++ b/react_on_rails_pro/spec/dummy/config/webpack/clientWebpackConfig.js @@ -1,6 +1,7 @@ const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin'); const LoadablePlugin = require('@loadable/webpack-plugin'); const commonWebpackConfig = require('./commonWebpackConfig'); +const rscManifestClientReferences = require('./rscManifestClientReferences'); const isHMR = process.env.HMR; @@ -16,10 +17,7 @@ const configureClient = () => { clientConfig.plugins.push( new RSCWebpackPlugin({ isServer: false, - // Limit client reference discovery to the app source directory to prevent - // the plugin from traversing into node_modules/ (which with pnpm workspace - // symlinks exposes .tsx source files that lack a configured loader) - clientReferences: [{ directory: './client/app', recursive: true, include: /\.(js|ts|jsx|tsx)$/ }], + clientReferences: rscManifestClientReferences(), }), ); diff --git a/react_on_rails_pro/spec/dummy/config/webpack/rscManifestClientReferences.js b/react_on_rails_pro/spec/dummy/config/webpack/rscManifestClientReferences.js new file mode 100644 index 0000000000..3e073b7823 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/config/webpack/rscManifestClientReferences.js @@ -0,0 +1,149 @@ +const fs = require('fs'); +const path = require('path'); +const { config } = require('shakapacker'); + +// Resolves the RSC client-reference manifest for the client and server webpack builds. +// +// This intentionally mirrors, branch for branch, the resolution contract the react_on_rails RSC +// setup generator emits inline (`rsc_client_references_js` in +// react_on_rails/lib/generators/react_on_rails/rsc_setup/client_references.rb). The shared contract +// that must stay in sync on both sides: +// - override env var: RSC_MANIFEST_CLIENT_REFERENCES_JSON (path.resolve'd; must exist, else throw +// "... is set but the file does not exist", then read) +// - default manifest: ssr-generated/rsc-client-references.json (resolved against the Rails root) +// - manifest shape: { refs: [...] }, else throw "... to contain a refs array" +// - parse errors: malformed JSON re-thrown as "Failed to parse RSC client references manifest ..." +// - fallback ordering: configured JSON -> (discovery/bundle-only build -> broad fallback) -> +// default JSON -> (registration entry present with old discovery tooling -> warn and broad +// fallback) -> (registration entry present with current tooling -> throw the precompile-hook +// hint) -> broad fallback +// - staleness warning: selected manifest older than the registration entry -> console.warn (non-fatal) +// - precompile hint: "Run bin/shakapacker-precompile-hook before bin/shakapacker." +// Both sides are pinned by contract tests so drift on either side fails CI: this resolver by the +// dummy-root tests/rsc-manifest-client-references.test.js (run by the Pro `package-js-tests` CI +// job), and the generator by react_on_rails/spec/react_on_rails/generators/rsc_generator_spec.rb. +// +// The manifest is a point-in-time snapshot regenerated by bin/shakapacker-precompile-hook, not on +// every webpack rebuild: a `--watch` build reads it once at startup, so a new 'use client' file +// added mid-session is not picked up until the watch restarts. See warnIfManifestStale below. +// +// Only the fallback `directory` (`./client/app`) is intentionally app-specific. It is deliberately +// scoped to the app source directory to keep the broad scan from traversing into node_modules/ +// (which, with pnpm workspace symlinks, exposes .tsx source files that lack a configured loader). +// The `include` extension set is kept byte-identical to the generator template and is pinned in +// lockstep by the contract test. +const DEFAULT_CLIENT_REFERENCES = [ + { directory: './client/app', recursive: true, include: /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/ }, +]; + +// cwd-relative (the Rails root at webpack build time), matching the generated template's +// `resolve('ssr-generated/rsc-client-references.json')` rather than coupling to this file's location. +const DEFAULT_REFERENCES_JSON = path.resolve('ssr-generated/rsc-client-references.json'); +const SERVER_COMPONENT_REGISTRATION_ENTRY = path.resolve( + config.source_path, + config.source_entry_path, + '../generated/server-component-registration-entry.js', +); + +function readManifestReferences(refsJson) { + let payload; + try { + payload = JSON.parse(fs.readFileSync(refsJson, 'utf8')); + } catch (err) { + throw new Error(`Failed to parse RSC client references manifest ${refsJson}: ${err.message}`); + } + if (!Array.isArray(payload.refs)) { + throw new Error(`Expected ${refsJson} to contain a refs array`); + } + + return payload.refs; +} + +// Warn (do not fail) when the discovered manifest predates the registration entry: the set of +// server components changed but the discovery build was not re-run, so the refs may be stale. This +// is best-effort — it cannot detect a new 'use client' file reached by an unchanged server +// component (that is the `--watch` snapshot limitation documented above). +function warnIfManifestStale(refsJson) { + // Best-effort: statSync can race a file removed between the existsSync guard and here, so swallow + // that rather than crash the build over a warning. + try { + if ( + fs.existsSync(SERVER_COMPONENT_REGISTRATION_ENTRY) && + fs.statSync(SERVER_COMPONENT_REGISTRATION_ENTRY).mtimeMs > fs.statSync(refsJson).mtimeMs + ) { + console.warn( + `[react_on_rails] ${refsJson} is older than the server component ` + + 'registration entry; RSC client references may be stale. ' + + 'Re-run bin/shakapacker-precompile-hook.', + ); + } + } catch { + // manifest or registration entry vanished between existsSync and statSync — skip the warning + } +} + +function fileContainsAll(filePath, tokens) { + try { + if (!fs.existsSync(filePath)) return false; + + const content = fs.readFileSync(filePath, 'utf8'); + return tokens.every((token) => content.includes(token)); + } catch { + return false; + } +} + +function rscConfigSupportsDiscovery() { + const rscWebpackConfig = path.resolve('config/webpack/rscWebpackConfig.js'); + const precompileHook = path.resolve('bin/shakapacker-precompile-hook'); + + return ( + fileContainsAll(rscWebpackConfig, ['RSC_REFERENCE_DISCOVERY_BUILD', 'RSCReferenceDiscoveryPlugin']) && + fileContainsAll(precompileHook, [ + 'generate_rsc_manifest_client_references_if_needed', + 'RSC_REFERENCE_DISCOVERY_BUILD', + ]) + ); +} + +function rscManifestClientReferences() { + const configuredRefsJson = process.env.RSC_MANIFEST_CLIENT_REFERENCES_JSON; + if (configuredRefsJson) { + const resolvedRefsJson = path.resolve(configuredRefsJson); + if (!fs.existsSync(resolvedRefsJson)) { + throw new Error( + `RSC_MANIFEST_CLIENT_REFERENCES_JSON is set but the file does not exist: ${resolvedRefsJson}`, + ); + } + warnIfManifestStale(resolvedRefsJson); + return readManifestReferences(resolvedRefsJson); + } + + if (process.env.RSC_REFERENCE_DISCOVERY_BUILD === 'true' || process.env.RSC_BUNDLE_ONLY === 'true') { + return DEFAULT_CLIENT_REFERENCES; + } + + if (fs.existsSync(DEFAULT_REFERENCES_JSON)) { + warnIfManifestStale(DEFAULT_REFERENCES_JSON); + return readManifestReferences(DEFAULT_REFERENCES_JSON); + } + + if (fs.existsSync(SERVER_COMPONENT_REGISTRATION_ENTRY)) { + if (!rscConfigSupportsDiscovery()) { + console.warn( + `[react_on_rails] Missing ${DEFAULT_REFERENCES_JSON}, but this app's RSC webpack config ` + + 'or precompile hook does not support manifest discovery yet; falling back to broad client ' + + 'reference scan. Re-run rails g react_on_rails:rsc to update generated configs.', + ); + return DEFAULT_CLIENT_REFERENCES; + } + + throw new Error( + `Missing ${DEFAULT_REFERENCES_JSON}. Run bin/shakapacker-precompile-hook before bin/shakapacker.`, + ); + } + + return DEFAULT_CLIENT_REFERENCES; +} + +module.exports = rscManifestClientReferences; diff --git a/react_on_rails_pro/spec/dummy/config/webpack/rscWebpackConfig.js b/react_on_rails_pro/spec/dummy/config/webpack/rscWebpackConfig.js index 9ce6669fae..f049e0d117 100644 --- a/react_on_rails_pro/spec/dummy/config/webpack/rscWebpackConfig.js +++ b/react_on_rails_pro/spec/dummy/config/webpack/rscWebpackConfig.js @@ -1,14 +1,49 @@ -const { resolve } = require('path'); +const { existsSync } = require('fs'); +const { dirname, resolve } = require('path'); +const { config } = require('shakapacker'); const { default: serverWebpackConfig, extractLoader } = require('./serverWebpackConfig'); +const rscReferenceDiscoveryPlugin = () => { + try { + // eslint-disable-next-line global-require + return require('react-on-rails-rsc/RSCReferenceDiscoveryPlugin').RSCReferenceDiscoveryPlugin; + } catch (error) { + throw new Error( + `Missing react-on-rails-rsc/RSCReferenceDiscoveryPlugin. ` + + `Install react-on-rails-rsc with RSCReferenceDiscoveryPlugin support ` + + `(check package.json for the required peer range) before running ` + + `bin/shakapacker-precompile-hook. ${error.message}`, + ); + } +}; + const configureRsc = () => { const rscConfig = serverWebpackConfig(true); + const discoveryBuild = process.env.RSC_REFERENCE_DISCOVERY_BUILD === 'true'; - // Update the entry name to be `rsc-bundle` instead of `server-bundle` - const rscEntry = { - 'rsc-bundle': rscConfig.entry['server-bundle'], - }; - rscConfig.entry = rscEntry; + const sourceEntryDirectory = resolve(config.source_path, config.source_entry_path); + const serverComponentRegistrationEntry = resolve( + dirname(sourceEntryDirectory), + 'generated/server-component-registration-entry.js', + ); + + if (discoveryBuild) { + if (!existsSync(serverComponentRegistrationEntry)) { + throw new Error( + `Missing server component registration entry: ${serverComponentRegistrationEntry}. ` + + `Run bin/shakapacker-precompile-hook before bin/shakapacker.`, + ); + } + + rscConfig.entry = { + 'rsc-reference-discovery': serverComponentRegistrationEntry, + }; + } else { + // Update the entry name to be `rsc-bundle` instead of `server-bundle` + rscConfig.entry = { + 'rsc-bundle': rscConfig.entry['server-bundle'], + }; + } // Add the RSC loader before the babel loader const { rules } = rscConfig.module; @@ -55,8 +90,14 @@ const configureRsc = () => { }, }; - // Update the output bundle name to be `rsc-bundle.js` instead of `server-bundle.js` - rscConfig.output.filename = 'rsc-bundle.js'; + if (discoveryBuild) { + rscConfig.output.filename = 'rsc-reference-discovery.js'; + const RSCReferenceDiscoveryPlugin = rscReferenceDiscoveryPlugin(); + rscConfig.plugins.push(new RSCReferenceDiscoveryPlugin()); + } else { + // Update the output bundle name to be `rsc-bundle.js` instead of `server-bundle.js` + rscConfig.output.filename = 'rsc-bundle.js'; + } return rscConfig; }; diff --git a/react_on_rails_pro/spec/dummy/config/webpack/serverWebpackConfig.js b/react_on_rails_pro/spec/dummy/config/webpack/serverWebpackConfig.js index a03f656f38..50e484a767 100644 --- a/react_on_rails_pro/spec/dummy/config/webpack/serverWebpackConfig.js +++ b/react_on_rails_pro/spec/dummy/config/webpack/serverWebpackConfig.js @@ -3,6 +3,7 @@ const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin'); const webpack = require('webpack'); const path = require('path'); const commonWebpackConfig = require('./commonWebpackConfig'); +const rscManifestClientReferences = require('./rscManifestClientReferences'); function extractLoader(rule, loaderName) { if (!Array.isArray(rule.use)) return null; @@ -84,10 +85,7 @@ const configureServer = (rscBundle = false) => { serverWebpackConfig.plugins.push( new RSCWebpackPlugin({ isServer: true, - // Limit client reference discovery to the app source directory to prevent - // the plugin from traversing into node_modules/ (which with pnpm workspace - // symlinks exposes .tsx source files that lack a configured loader) - clientReferences: [{ directory: './client/app', recursive: true, include: /\.(js|ts|jsx|tsx)$/ }], + clientReferences: rscManifestClientReferences(), }), ); } diff --git a/react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts b/react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts index eaacb70ef5..394df835d9 100644 --- a/react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts +++ b/react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts @@ -1,6 +1,7 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; const STRESS_URL = '/server_router/refetch-stress'; +const visibleByTestId = (page: Page, testId: string) => page.getByTestId(testId).filter({ visible: true }); test.describe('Imperative RSC refetch — stress scenarios (Issue 3106)', () => { test.beforeEach(async ({ page }) => { @@ -9,36 +10,36 @@ test.describe('Imperative RSC refetch — stress scenarios (Issue 3106)', () => // change baseURL in playwright.config.ts (it does not currently honor a // BASE_URL env var override). await page.goto(STRESS_URL); - await expect(page.getByTestId('stress-time-ref-handle')).toBeVisible(); + await expect(visibleByTestId(page, 'stress-time-ref-handle')).toBeVisible(); }); test('1. ref handle: button click visibly refreshes the timestamp', async ({ page }) => { - const before = await page.getByTestId('stress-time-ref-handle').textContent(); - await page.getByTestId('ref-refetch-button').click(); + const before = await visibleByTestId(page, 'stress-time-ref-handle').textContent(); + await visibleByTestId(page, 'ref-refetch-button').click(); await expect - .poll(() => page.getByTestId('stress-time-ref-handle').textContent(), { timeout: 5000 }) + .poll(() => visibleByTestId(page, 'stress-time-ref-handle').textContent(), { timeout: 5000 }) .not.toBe(before); }); test('2. inside-RSC hook: button click visibly refreshes the timestamp', async ({ page }) => { - const before = await page.getByTestId('stress-time-inside-hook').textContent(); - await page.getByTestId('stress-inline-inside-hook').click(); + const before = await visibleByTestId(page, 'stress-time-inside-hook').textContent(); + await visibleByTestId(page, 'stress-inline-inside-hook').click(); await expect - .poll(() => page.getByTestId('stress-time-inside-hook').textContent(), { timeout: 5000 }) + .poll(() => visibleByTestId(page, 'stress-time-inside-hook').textContent(), { timeout: 5000 }) .not.toBe(before); }); test('3. multi-instance fan-out: two cards with same key both update on one refetch', async ({ page }) => { // Two separate in DOM — they share // the cache key, so both must update together. - const initial = await page.getByTestId('stress-time-shared').allTextContents(); + const initial = await visibleByTestId(page, 'stress-time-shared').allTextContents(); expect(initial).toHaveLength(2); expect(initial[0]).toBe(initial[1]); // initial render: same payload - await page.getByTestId('multi-refetch-button').click(); + await visibleByTestId(page, 'multi-refetch-button').click(); await expect .poll( async () => { - const ts = await page.getByTestId('stress-time-shared').allTextContents(); + const ts = await visibleByTestId(page, 'stress-time-shared').allTextContents(); return ts.length === 2 && ts[0] === ts[1] && ts[0] !== initial[0]; }, { timeout: 5000 }, @@ -47,50 +48,50 @@ test.describe('Imperative RSC refetch — stress scenarios (Issue 3106)', () => }); test('4. independent siblings: refreshing left does not change right', async ({ page }) => { - const leftBefore = await page.getByTestId('stress-time-indep-left').textContent(); - const rightBefore = await page.getByTestId('stress-time-indep-right').textContent(); - await page.getByTestId('indep-left-button').click(); + const leftBefore = await visibleByTestId(page, 'stress-time-indep-left').textContent(); + const rightBefore = await visibleByTestId(page, 'stress-time-indep-right').textContent(); + await visibleByTestId(page, 'indep-left-button').click(); await expect - .poll(() => page.getByTestId('stress-time-indep-left').textContent(), { timeout: 5000 }) + .poll(() => visibleByTestId(page, 'stress-time-indep-left').textContent(), { timeout: 5000 }) .not.toBe(leftBefore); - expect(await page.getByTestId('stress-time-indep-right').textContent()).toBe(rightBefore); + expect(await visibleByTestId(page, 'stress-time-indep-right').textContent()).toBe(rightBefore); }); test('5. captured handle: refetch after props change uses the LATEST props', async ({ page }) => { // step 1: capture refetch when label = captured-v1 - await page.getByTestId('captured-grab').click(); + await visibleByTestId(page, 'captured-grab').click(); // step 2: change props (label becomes captured-v2) - await page.getByTestId('captured-bump').click(); + await visibleByTestId(page, 'captured-bump').click(); // wait for the card to mount under the new key - await expect(page.getByTestId('stress-card-captured-v2')).toBeVisible(); - const before = await page.getByTestId('stress-time-captured-v2').textContent(); + await expect(visibleByTestId(page, 'stress-card-captured-v2')).toBeVisible(); + const before = await visibleByTestId(page, 'stress-time-captured-v2').textContent(); // step 3: invoke the captured handle. It should refetch v2's payload. - await page.getByTestId('captured-invoke').click(); + await visibleByTestId(page, 'captured-invoke').click(); await expect - .poll(() => page.getByTestId('stress-time-captured-v2').textContent(), { timeout: 5000 }) + .poll(() => visibleByTestId(page, 'stress-time-captured-v2').textContent(), { timeout: 5000 }) .not.toBe(before); }); test('6. rapid double-click: UI ends up showing the latest payload', async ({ page }) => { - const before = await page.getByTestId('stress-time-rapid').textContent(); - const rapidButton = page.getByTestId('rapid-button'); + const before = await visibleByTestId(page, 'stress-time-rapid').textContent(); + const rapidButton = visibleByTestId(page, 'rapid-button'); await rapidButton.click(); await rapidButton.click(); await expect - .poll(() => page.getByTestId('stress-time-rapid').textContent(), { timeout: 5000 }) + .poll(() => visibleByTestId(page, 'stress-time-rapid').textContent(), { timeout: 5000 }) .not.toBe(before); }); test('7. many siblings: refresh-all updates each card independently', async ({ page }) => { const before = await Promise.all( - [0, 1, 2, 3, 4].map((i) => page.getByTestId(`stress-time-many-${i}`).textContent()), + [0, 1, 2, 3, 4].map((i) => visibleByTestId(page, `stress-time-many-${i}`).textContent()), ); - await page.getByTestId('many-refresh-all').click(); + await visibleByTestId(page, 'many-refresh-all').click(); await expect .poll( async () => { const after = await Promise.all( - [0, 1, 2, 3, 4].map((i) => page.getByTestId(`stress-time-many-${i}`).textContent()), + [0, 1, 2, 3, 4].map((i) => visibleByTestId(page, `stress-time-many-${i}`).textContent()), ); return after.every((v, i) => v && v !== before[i]); }, @@ -102,21 +103,21 @@ test.describe('Imperative RSC refetch — stress scenarios (Issue 3106)', () => test('8. mount/unmount: ref.current is null after unmount, set after re-mount', async ({ page }) => { // The page renders 'unchecked' until the button is pressed, so the // first assertion is a real ref read, not a seeded display value. - await expect(page.getByTestId('mount-ref-state')).toHaveText('ref.current: unchecked'); + await expect(visibleByTestId(page, 'mount-ref-state')).toHaveText('ref.current: unchecked'); // While mounted, the ref is set. - await page.getByTestId('mount-check-ref').click(); - await expect(page.getByTestId('mount-ref-state')).toHaveText('ref.current: set'); + await visibleByTestId(page, 'mount-check-ref').click(); + await expect(visibleByTestId(page, 'mount-ref-state')).toHaveText('ref.current: set'); // unmount - await page.getByTestId('mount-toggle').click(); - await page.getByTestId('mount-check-ref').click(); - await expect(page.getByTestId('mount-ref-state')).toHaveText('ref.current: null'); + await visibleByTestId(page, 'mount-toggle').click(); + await visibleByTestId(page, 'mount-check-ref').click(); + await expect(visibleByTestId(page, 'mount-ref-state')).toHaveText('ref.current: null'); // re-mount - await page.getByTestId('mount-toggle').click(); - await expect(page.getByTestId('stress-card-mount-cycle')).toBeVisible(); - await page.getByTestId('mount-check-ref').click(); - await expect(page.getByTestId('mount-ref-state')).toHaveText('ref.current: set'); + await visibleByTestId(page, 'mount-toggle').click(); + await expect(visibleByTestId(page, 'stress-card-mount-cycle')).toBeVisible(); + await visibleByTestId(page, 'mount-check-ref').click(); + await expect(visibleByTestId(page, 'mount-ref-state')).toHaveText('ref.current: set'); }); }); diff --git a/react_on_rails_pro/spec/dummy/e2e-tests/rsc_use_client_css.spec.ts b/react_on_rails_pro/spec/dummy/e2e-tests/rsc_use_client_css.spec.ts index 762e7dcbfd..efab3a7c79 100644 --- a/react_on_rails_pro/spec/dummy/e2e-tests/rsc_use_client_css.spec.ts +++ b/react_on_rails_pro/spec/dummy/e2e-tests/rsc_use_client_css.spec.ts @@ -3,8 +3,10 @@ import { test, expect } from '@playwright/test'; const CSS_PROBE_PATH = '/rsc_posts_page_over_http?posts_count=0'; // Regression test for issue #3211: CSS imported behind a `'use client'` boundary -// in a true RSC tree must be preloaded through React's stylesheet precedence -// stream bootstrap, so the browser waits for it before revealing the boundary. +// in a true RSC tree must be present before the boundary content that needs it. +// React 19 may serialize that as preload/bootstrap hints or as a hoisted +// precedence link; either shape lets the browser load the stylesheet before +// painting the probe. // Without the fix the stylesheet only loads as a side effect of the JS chunk // evaluating, producing a flash of unstyled content (FOUC). // @@ -16,16 +18,23 @@ test.describe('RSC use-client CSS (#3211 FOUC fix)', () => { expect(response.ok()).toBe(true); const ssrHtml = await response.text(); - // No-FOUC guarantee: the stream includes CSS preloads plus React's reveal - // bootstrap for the precedence group, so the browser waits on the stylesheet - // before revealing the streamed boundary. - expect(ssrHtml).toMatch( - /]*\brel="preload")(?=[^>]*\bas="style")(?=[^>]*\bhref="[^"]*\.css")[^>]*>/, + // No-FOUC guarantee: local and CI streams can differ between React's native + // RSC stylesheet hints and our wrapper precedence link. Accept either the + // preload/bootstrap pair or a blocking stylesheet link before the SSR probe. + const hasStylePreload = + /]*\brel="preload")(?=[^>]*\bas="style")(?=[^>]*\bhref="[^"]*\.css")[^>]*>/.test(ssrHtml); + const hasPrecedenceBootstrap = + /\[\s*"[^"]*\.css"\s*,\s*"(?:ror-rsc|rsc-css)"\s*\]/.test(ssrHtml) || + /HS\[\\"[^"]*\.css\\"\s*,\s*\\"(?:ror-rsc|rsc-css)\\"\]/.test(ssrHtml); + const stylesheetLinkMatch = ssrHtml.match( + /]*\brel="stylesheet")(?=[^>]*\bdata-precedence="(?:ror-rsc|rsc-css)")(?=[^>]*\bhref="[^"]*\.css")[^>]*>/, ); - // React serializes the precedence reveal as a ["