[Pro] Add RSC SSR synchrony regression tests: complete payload must render before setTimeout(0) #9267
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Rspec test for gem | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: >- | |
| ${{ | |
| github.event.pull_request.number && format('{0}-pr-{1}', github.workflow, github.event.pull_request.number) || | |
| format('{0}-{1}', github.workflow, github.sha) | |
| }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| on: | |
| push: | |
| branches: | |
| - 'main' | |
| - 'release/**' | |
| # Always trigger on main/release branches; non-runtime detection handles skipping heavy jobs | |
| pull_request: | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| merge_group: | |
| workflow_dispatch: | |
| inputs: | |
| force_full_hosted: | |
| description: 'Force full hosted CI (bypass optimized detect-changes)' | |
| required: false | |
| type: boolean | |
| default: false | |
| pull_request_base_ref: | |
| description: 'Pull request base branch for label/command dispatches' | |
| required: false | |
| type: string | |
| pull_request_base_sha: | |
| description: 'Pull request base SHA for label/command dispatches' | |
| required: false | |
| type: string | |
| jobs: | |
| detect-changes: | |
| # Branch deletion push events have no checkoutable release ref. | |
| if: github.event_name != 'push' || github.event.deleted != true | |
| permissions: | |
| contents: read | |
| actions: read | |
| issues: read | |
| runs-on: ubuntu-22.04 | |
| outputs: | |
| docs_only: ${{ steps.detect.outputs.docs_only }} | |
| non_runtime_only: ${{ steps.detect.outputs.non_runtime_only }} | |
| run_lint: ${{ steps.detect.outputs.run_lint }} | |
| run_js_tests: ${{ steps.detect.outputs.run_js_tests }} | |
| run_ruby_tests: ${{ steps.detect.outputs.run_ruby_tests }} | |
| run_gem_generator_specs: ${{ steps.detect.outputs.run_gem_generator_specs }} | |
| run_dummy_tests: ${{ steps.detect.outputs.run_dummy_tests }} | |
| run_generators: ${{ steps.detect.outputs.run_generators }} | |
| should_run_hosted_ci: ${{ steps.hosted-ci.outputs.should_run_hosted_ci }} | |
| should_force_full_hosted_ci: ${{ steps.hosted-ci.outputs.should_force_full_hosted_ci }} | |
| should_use_full_matrix: ${{ steps.hosted-ci.outputs.should_use_full_matrix }} | |
| matrix: ${{ steps.set-matrix.outputs.matrix }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # Full history is only needed for workflow_dispatch base-SHA reruns. | |
| fetch-depth: ${{ github.event_name == 'workflow_dispatch' && '0' || '50' }} | |
| persist-credentials: false | |
| - name: Select hosted CI mode | |
| id: hosted-ci | |
| uses: ./.github/actions/hosted-ci-selectors | |
| with: | |
| force-full-hosted: ${{ inputs.force_full_hosted || false }} | |
| base-ref: ${{ inputs.pull_request_base_ref || '' }} | |
| - name: Detect relevant changes | |
| id: detect | |
| env: | |
| DISPATCH_BASE_REF: ${{ inputs.pull_request_base_ref || '' }} | |
| DISPATCH_BASE_SHA: ${{ inputs.pull_request_base_sha || '' }} | |
| run: | | |
| # Force-full hosted CI is the explicit escape hatch that bypasses | |
| # optimized change selection. Hosted readiness alone stays path-based. | |
| if [ "${{ steps.hosted-ci.outputs.should_force_full_hosted_ci }}" = "true" ]; then | |
| { | |
| echo "run_lint=true" | |
| echo "run_js_tests=true" | |
| echo "run_ruby_tests=true" | |
| echo "run_gem_generator_specs=true" | |
| echo "run_dummy_tests=true" | |
| echo "run_generators=true" | |
| echo "docs_only=false" | |
| echo "non_runtime_only=false" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ -n "$DISPATCH_BASE_SHA" ]; then | |
| export GITHUB_BASE_REF="$DISPATCH_BASE_REF" | |
| BASE_REF="$DISPATCH_BASE_SHA" | |
| else | |
| BASE_REF="${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'origin/main' }}" | |
| fi | |
| script/ci-changes-detector "$BASE_REF" | |
| shell: bash | |
| - name: Read runtime versions | |
| id: tool-versions | |
| uses: ./.github/actions/read-tool-versions | |
| - name: Set gem tests matrix | |
| id: set-matrix | |
| env: | |
| LATEST_RUBY_VERSION: ${{ steps.tool-versions.outputs.ruby-minor-version }} | |
| MINIMUM_RUBY_VERSION: ${{ steps.tool-versions.outputs.minimum-ruby-minor-version }} | |
| SHOULD_USE_FULL_MATRIX: ${{ steps.hosted-ci.outputs.should_use_full_matrix }} | |
| RUN_GEM_GENERATOR_SPECS: ${{ steps.detect.outputs.run_gem_generator_specs }} | |
| run: | | |
| latest_ruby_version="$LATEST_RUBY_VERSION" | |
| minimum_ruby_version="$MINIMUM_RUBY_VERSION" | |
| # Full matrices run only on main, merge queue, release targets, or | |
| # explicit force-full hosted CI. Normal hosted PR CI stays optimized. | |
| # Each Ruby version / dependency level keeps one unit shard and splits | |
| # spec/react_on_rails/generators across three deterministic subshards | |
| # while keeping context-hook setup atomic. | |
| if [[ "$SHOULD_USE_FULL_MATRIX" == "true" ]]; then | |
| # Full matrix: test both latest and minimum supported versions. | |
| matrix="$(jq -nc \ | |
| --arg latest_ruby_version "$latest_ruby_version" \ | |
| --arg minimum_ruby_version "$minimum_ruby_version" \ | |
| '{ | |
| "include": [ | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "generators-1"}, | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "generators-2"}, | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "generators-3"}, | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "unit"}, | |
| {"ruby-version": $minimum_ruby_version, "dependency-level": "minimum", "shard": "generators-1"}, | |
| {"ruby-version": $minimum_ruby_version, "dependency-level": "minimum", "shard": "generators-2"}, | |
| {"ruby-version": $minimum_ruby_version, "dependency-level": "minimum", "shard": "generators-3"}, | |
| {"ruby-version": $minimum_ruby_version, "dependency-level": "minimum", "shard": "unit"} | |
| ] | |
| }')" | |
| else | |
| # PR matrix: always run the latest unit shard, and add generator | |
| # specs only when their dedicated selector requires them. | |
| if [[ "$RUN_GEM_GENERATOR_SPECS" == "true" ]]; then | |
| matrix="$(jq -nc \ | |
| --arg latest_ruby_version "$latest_ruby_version" \ | |
| '{ | |
| "include": [ | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "generators-1"}, | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "generators-2"}, | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "generators-3"}, | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "unit"} | |
| ] | |
| }')" | |
| else | |
| matrix="$(jq -nc \ | |
| --arg latest_ruby_version "$latest_ruby_version" \ | |
| '{ | |
| "include": [ | |
| {"ruby-version": $latest_ruby_version, "dependency-level": "latest", "shard": "unit"} | |
| ] | |
| }')" | |
| fi | |
| fi | |
| echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" | |
| - name: Guard docs-only main pushes | |
| if: | | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| (github.event_name == 'merge_group' && | |
| github.event.merge_group.base_ref == 'refs/heads/main') | |
| uses: ./.github/actions/ensure-main-docs-safety | |
| with: | |
| docs-only: ${{ steps.detect.outputs.docs_only }} | |
| previous-sha: ${{ github.event.before || github.event.merge_group.base_sha }} | |
| rspec-package-tests: | |
| needs: detect-changes | |
| # Run hosted gem tests only when hosted CI is allowed and Ruby or generator-spec coverage is required. | |
| if: | | |
| needs.detect-changes.outputs.should_run_hosted_ci == 'true' && | |
| (needs.detect-changes.outputs.run_ruby_tests == 'true' || | |
| needs.detect-changes.outputs.run_gem_generator_specs == 'true') | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }} | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| persist-credentials: false | |
| - name: Setup Ruby | |
| uses: ./.github/actions/setup-ruby | |
| with: | |
| ruby-version: ${{ matrix.ruby-version }} | |
| - name: Print system information | |
| run: | | |
| echo "Linux release: "; cat /etc/issue | |
| echo "Current user: "; whoami | |
| echo "Current directory: "; pwd | |
| echo "Ruby version: "; ruby -v | |
| echo "Node version: "; node -v | |
| echo "Bundler version: "; bundle --version | |
| - name: run conversion script to use minimum supported dependency versions | |
| if: matrix.dependency-level == 'minimum' | |
| run: script/convert | |
| - name: Install Ruby Gems for package | |
| uses: ./.github/actions/setup-bundle | |
| with: | |
| working-directory: react_on_rails | |
| ruby-version: ${{ matrix.ruby-version }} | |
| # Minimum-dependency legs intentionally install with frozen=false after script/convert, | |
| # matching the historical bundle install behavior for converted dependency files. | |
| frozen: ${{ matrix.dependency-level == 'minimum' && 'false' || 'true' }} | |
| install-libyaml: 'false' | |
| - name: Git Stuff | |
| if: matrix.dependency-level == 'minimum' | |
| run: | | |
| git config user.email "you@example.com" | |
| git config user.name "Your Name" | |
| git commit -am "stop generators from complaining about uncommitted code" | |
| - name: Set dependency level environment variable | |
| run: | | |
| echo "CI_DEPENDENCY_LEVEL=${{ matrix.dependency-level }}" >> "$GITHUB_ENV" | |
| - name: Run rspec tests | |
| env: | |
| SHARD: ${{ matrix.shard }} | |
| run: | | |
| cd react_on_rails | |
| if [[ "$SHARD" == generators-* ]]; then | |
| shard_number="${SHARD#generators-}" | |
| # Keep this aligned with the generators-1/2/3 rows in both matrix branches above. | |
| shard_count=3 | |
| shard_index="$((shard_number - 1))" | |
| manifest_path="${RUNNER_TEMP}/generator-shard-${shard_number}.opts" | |
| # Rebuild from each job's exact checkout; never share a manifest across jobs or heads. | |
| bundle exec ruby -rrspec/core -rdigest -e ' | |
| shard_index = Integer(ARGV.fetch(0)) | |
| shard_count = Integer(ARGV.fetch(1)) | |
| manifest_path = ARGV.fetch(2) | |
| abort "invalid generator shard index" unless shard_index.between?(0, shard_count - 1) | |
| sink = File.open(File::NULL, "w") | |
| status = RSpec::Core::Runner.run( | |
| ["spec/react_on_rails/generators", "--dry-run", "--format", "progress"], | |
| sink, | |
| sink | |
| ) | |
| abort "generator manifest dry run failed with status #{status}" unless status.zero? | |
| id_for = lambda do |item| | |
| metadata = item.metadata | |
| "#{metadata.fetch(:rerun_file_path)}[#{metadata.fetch(:scoped_id)}]" | |
| end | |
| examples = RSpec.world.all_examples.map do |example| | |
| metadata = example.metadata | |
| file = metadata.fetch(:rerun_file_path) | |
| scoped_id = metadata.fetch(:scoped_id) | |
| { id: id_for.call(example), file:, scoped_id: } | |
| end | |
| ids = examples.map { |row| row.fetch(:id) } | |
| abort "generator manifest is empty" if ids.empty? | |
| abort "generator manifest has duplicate scoped IDs" unless ids.uniq.length == ids.length | |
| atomic_unit_by_id = {} | |
| setup_count_by_unit = Hash.new(0) | |
| visit_group = lambda do |group| | |
| hooks = group.hooks | |
| owns_context_hook = %i[before after].any? do |position| | |
| # RSpec has no public hook-enumeration API; fail loudly if this private seam changes. | |
| !hooks.send(:all_hooks_for, position, :context).empty? | |
| end | |
| if owns_context_hook | |
| descendants = group.descendant_filtered_examples | |
| existing_units = descendants.filter_map do |example| | |
| atomic_unit_by_id[id_for.call(example)] | |
| end.uniq | |
| abort "nested generator setup spans multiple atomic units" if existing_units.length > 1 | |
| own_unit_id = id_for.call(group) | |
| unit_id = existing_units.first || own_unit_id | |
| descendants.each do |example| | |
| atomic_unit_by_id[id_for.call(example)] = unit_id | |
| end | |
| setup_count_by_unit[unit_id] += 1 | |
| end | |
| group.children.each { |child| visit_group.call(child) } | |
| end | |
| RSpec.world.example_groups.each { |group| visit_group.call(group) } | |
| units = examples.group_by do |row| | |
| id = row.fetch(:id) | |
| atomic_unit_by_id.fetch(id, id) | |
| end.to_a | |
| weight_by_unit = units.to_h do |unit_id, rows| | |
| [unit_id, rows.length + setup_count_by_unit.fetch(unit_id, 0)] | |
| end | |
| shards = Array.new(shard_count) { [] } | |
| shard_loads = Array.new(shard_count, 0) | |
| shard_example_counts = Array.new(shard_count, 0) | |
| units.sort_by do |unit_id, rows| | |
| [-weight_by_unit.fetch(unit_id), Digest::SHA256.hexdigest(unit_id), unit_id] | |
| end.each do |unit_id, rows| | |
| unit_weight = weight_by_unit.fetch(unit_id) | |
| target = (0...shard_count).min_by do |index| | |
| [shard_loads[index], shard_example_counts[index], shards[index].length, index] | |
| end | |
| shards.fetch(target) << [unit_id, rows] | |
| shard_loads[target] += unit_weight | |
| shard_example_counts[target] += rows.length | |
| end | |
| assigned_ids = shards.flat_map do |shard_units| | |
| shard_units.flat_map { |_, rows| rows.map { |row| row.fetch(:id) } } | |
| end | |
| abort "generator partition lost or duplicated examples" unless assigned_ids.sort == ids.sort | |
| selected_units = shards.fetch(shard_index) | |
| selected = selected_units.flat_map(&:last) | |
| abort "generator shard #{shard_index + 1} is empty" if selected.empty? | |
| arguments = selected.group_by { |row| row.fetch(:file) }.map do |file, rows| | |
| scoped_ids = rows.map { |row| row.fetch(:scoped_id) } | |
| "#{file}[#{scoped_ids.join(",")}]" | |
| end | |
| File.write(manifest_path, arguments.join("\n") << "\n") | |
| puts "generator shard #{shard_index + 1}/#{shard_count}: " \ | |
| "#{selected.length}/#{ids.length} examples in #{selected_units.length} setup-safe units " \ | |
| "across #{arguments.length} files" | |
| ' "$shard_index" "$shard_count" "$manifest_path" | |
| specs=() | |
| while IFS= read -r spec; do | |
| specs+=("$spec") | |
| done < "$manifest_path" | |
| bundle exec rspec "${specs[@]}" | |
| else | |
| bundle exec rspec spec/react_on_rails --exclude-pattern "**/generators/**" | |
| fi | |
| - name: Store test results | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: main-rspec-${{ github.run_id }}-${{ github.job }}-ruby${{ matrix.ruby-version }}-${{ matrix.dependency-level }}-${{ matrix.shard }} | |
| path: ~/rspec | |
| - name: Store artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: main-test-log-${{ github.run_id }}-${{ github.job }}-ruby${{ matrix.ruby-version }}-${{ matrix.dependency-level }}-${{ matrix.shard }} | |
| path: log/test.log |