Skip to content

[Pro] Add RSC SSR synchrony regression tests: complete payload must render before setTimeout(0) #9267

[Pro] Add RSC SSR synchrony regression tests: complete payload must render before setTimeout(0)

[Pro] Add RSC SSR synchrony regression tests: complete payload must render before setTimeout(0) #9267

Workflow file for this run

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