diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile index 9a955122..c18eb985 100644 --- a/.controlplane/Dockerfile +++ b/.controlplane/Dockerfile @@ -72,7 +72,8 @@ RUN SECRET_KEY_BASE=precompile_placeholder bin/rails react_on_rails:locale # and /app/client/app are the client assets that are bundled, so not needed once built # Helps to have smaller images b/c of smaller Docker Layer Caches and smaller final images # SECRET_KEY_BASE is required for asset precompilation but is not persisted in the image -RUN SECRET_KEY_BASE=precompile_placeholder yarn res:build && \ +RUN SECRET_KEY_BASE=precompile_placeholder bundle exec rake react_on_rails:generate_packs && \ + SECRET_KEY_BASE=precompile_placeholder yarn res:build && \ SECRET_KEY_BASE=precompile_placeholder bin/rails assets:precompile && \ rm -rf /app/lib/bs /app/client/app diff --git a/.controlplane/entrypoint.sh b/.controlplane/entrypoint.sh index d80de4c3..15eda64b 100755 --- a/.controlplane/entrypoint.sh +++ b/.controlplane/entrypoint.sh @@ -21,7 +21,7 @@ echo " -- Waiting for services" wait_for_service $(echo $DATABASE_URL | sed -e 's|^.*@||' -e 's|/.*$||') wait_for_service $(echo $REDIS_URL | sed -e 's|redis://||' -e 's|/.*$||') -echo " -- Finishing entrypoint.sh, executing '$@'" +echo " -- Finishing entrypoint.sh, executing '$*'" # Run the main command exec "$@" diff --git a/.controlplane/templates/org.yml b/.controlplane/templates/org.yml index 4be7ee03..0f3dd187 100644 --- a/.controlplane/templates/org.yml +++ b/.controlplane/templates/org.yml @@ -1,8 +1,10 @@ -# Org level secrets are used to store sensitive information that is -# shared across multiple apps in the same organization. This is -# useful for storing things like API keys, database credentials, and -# other sensitive information that is shared across multiple apps -# in the same organization. +# App secret dictionaries store sensitive information for apps in the +# organization. This template keeps the cpflow app-secret placeholders +# {{APP_SECRETS}} and {{APP_SECRETS_POLICY}}. +# +# cpflow 5.1.1 shared_secret_grants are only for a separate shared +# org-level dictionary referenced from app/workload templates with +# {{SHARED_SECRET_}}. # The qa-* dictionary is bootstrapped via this template for review apps. # Review apps run pull request code, so values in this dictionary must be @@ -29,7 +31,7 @@ data: --- -# Policy is needed to allow identities to access secrets +# App secret policy grants app identities reveal access to this dictionary. kind: policy name: {{APP_SECRETS_POLICY}} targetKind: secret diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml new file mode 100644 index 00000000..1bc59c00 --- /dev/null +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -0,0 +1,92 @@ +name: Build Docker Image +description: Builds and pushes the app image for a Control Plane workload + +inputs: + app_name: + description: Name of the application + required: true + org: + description: Control Plane organization name + required: true + commit: + description: Commit SHA to tag the image with + required: true + pr_number: + description: Pull request number for status messaging + required: false + docker_build_extra_args: + description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar. + required: false + docker_build_ssh_key: + description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh + required: false + docker_build_ssh_known_hosts: + description: Optional SSH known_hosts entries used with docker_build_ssh_key. Defaults to pinned GitHub.com host keys. + required: false + +outputs: + image_tag: + description: Fully qualified image tag + value: ${{ steps.build.outputs.image_tag }} + +runs: + using: composite + steps: + - name: Build Docker image + id: build + shell: bash + run: | + set -euo pipefail + + PR_INFO="" + docker_build_args=() + + if [[ -n "${{ inputs.pr_number }}" ]]; then + PR_INFO=" for PR #${{ inputs.pr_number }}" + fi + + if [[ -n "${{ inputs.docker_build_extra_args }}" ]]; then + while IFS= read -r arg; do + arg="${arg%$'\r'}" + [[ -n "${arg}" ]] || continue + + if [[ "${arg}" =~ [[:space:]] ]]; then + echo "docker_build_extra_args entries must be single docker-build tokens. " \ + "Use key=value forms like --build-arg=FOO=bar." >&2 + exit 1 + fi + + docker_build_args+=("${arg}") + done <<< "${{ inputs.docker_build_extra_args }}" + fi + + if [[ -n "${{ inputs.docker_build_ssh_key }}" ]]; then + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + if [[ -n "${{ inputs.docker_build_ssh_known_hosts }}" ]]; then + cat <<'EOF' > ~/.ssh/known_hosts + ${{ inputs.docker_build_ssh_known_hosts }} + EOF + else + cat <<'EOF' > ~/.ssh/known_hosts + github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl + github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= + github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= + EOF + fi + + chmod 600 ~/.ssh/known_hosts + + eval "$(ssh-agent -s)" + trap 'ssh-agent -k >/dev/null' EXIT + ssh-add - <<< "${{ inputs.docker_build_ssh_key }}" + docker_build_args+=("--ssh=default") + fi + + echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..." + cpflow build-image -a "${{ inputs.app_name }}" --commit="${{ inputs.commit }}" --org="${{ inputs.org }}" "${docker_build_args[@]}" + + image_tag="${{ inputs.org }}/${{ inputs.app_name }}:${{ inputs.commit }}" + echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" + echo "✅ Docker image build successful${PR_INFO} (commit ${{ inputs.commit }})" diff --git a/.github/actions/cpflow-delete-control-plane-app/action.yml b/.github/actions/cpflow-delete-control-plane-app/action.yml new file mode 100644 index 00000000..63981dd5 --- /dev/null +++ b/.github/actions/cpflow-delete-control-plane-app/action.yml @@ -0,0 +1,24 @@ +name: Delete Control Plane App +description: Deletes a Control Plane app and all associated resources + +inputs: + app_name: + description: Name of the application to delete + required: true + cpln_org: + description: Control Plane organization name + required: true + review_app_prefix: + description: Prefix used for review app names + required: true + +runs: + using: composite + steps: + - name: Delete application + shell: bash + run: ${{ github.action_path }}/delete-app.sh + env: + APP_NAME: ${{ inputs.app_name }} + CPLN_ORG: ${{ inputs.cpln_org }} + REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }} diff --git a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh new file mode 100755 index 00000000..6b70722a --- /dev/null +++ b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -euo pipefail + +: "${APP_NAME:?APP_NAME environment variable is required}" +: "${CPLN_ORG:?CPLN_ORG environment variable is required}" +: "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}" + +expected_prefix="${REVIEW_APP_PREFIX}-" +if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then + echo "❌ ERROR: refusing to delete an app outside the review app prefix" >&2 + echo "App name: $APP_NAME" >&2 + echo "Expected prefix: ${expected_prefix}" >&2 + exit 1 +fi + +echo "🔍 Checking if application exists: $APP_NAME" +exists_output="" +if ! exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"; then + case "$exists_output" in + *"Double check your org"*|*"Unknown API token format"*|*"ERROR"*|*"Error:"*|*"Traceback"*|*"Net::"*) + echo "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2 + printf '%s\n' "$exists_output" >&2 + exit 1 + ;; + esac + + if [[ -n "$exists_output" ]]; then + printf '%s\n' "$exists_output" + fi + + echo "⚠️ Application does not exist: $APP_NAME" + exit 0 +fi + +if [[ -n "$exists_output" ]]; then + printf '%s\n' "$exists_output" +fi + +echo "🗑️ Deleting application: $APP_NAME" +cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes + +echo "✅ Successfully deleted application: $APP_NAME" diff --git a/.github/actions/cpflow-setup-environment/action.yml b/.github/actions/cpflow-setup-environment/action.yml new file mode 100644 index 00000000..0ab65963 --- /dev/null +++ b/.github/actions/cpflow-setup-environment/action.yml @@ -0,0 +1,70 @@ +name: Setup Control Plane Environment +description: Sets up Ruby, installs the Control Plane CLI and cpflow gem, and configures a default profile + +inputs: + token: + description: Control Plane token + required: true + org: + description: Control Plane organization + required: true + ruby_version: + description: Ruby version used for cpflow + required: false + default: "3.4.6" + cpln_cli_version: + description: "@controlplane/cli version" + required: false + default: "3.3.1" + cpflow_version: + description: cpflow gem version + required: false + default: "5.1.1" + +runs: + using: composite + steps: + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ inputs.ruby_version }} + + - name: Install Control Plane CLI and cpflow gem + shell: bash + run: | + set -euo pipefail + + sudo npm install -g @controlplane/cli@${{ inputs.cpln_cli_version }} + cpln --version + + gem install cpflow -v ${{ inputs.cpflow_version }} + cpflow --version + + - name: Setup Control Plane profile and registry login + shell: bash + run: | + set -euo pipefail + + TOKEN="${{ inputs.token }}" + ORG="${{ inputs.org }}" + + if [[ -z "$TOKEN" ]]; then + echo "Error: Control Plane token not provided" >&2 + exit 1 + fi + + if [[ -z "$ORG" ]]; then + echo "Error: Control Plane organization not provided" >&2 + exit 1 + fi + + create_output="" + if ! create_output="$(cpln profile create default --token "$TOKEN" --org "$ORG" 2>&1)"; then + if ! echo "$create_output" | grep -qi "already exists"; then + echo "$create_output" >&2 + exit 1 + fi + fi + + cpln profile update default --org "$ORG" --token "$TOKEN" + cpln image docker-login --org "$ORG" diff --git a/Gemfile b/Gemfile index a6d32943..262c6d70 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,8 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby "3.4.6" gem "cpflow", "5.1.1", require: false -gem "react_on_rails_pro", "16.7.0.rc.3" +gem "react_on_rails", "17.0.0.rc.1" +gem "react_on_rails_pro", "17.0.0.rc.1" gem "shakapacker", "10.1.0" # Bundle edge Rails instead: gem "rails", github: "rails/rails" diff --git a/Gemfile.lock b/Gemfile.lock index d150882f..76cdc78e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -134,7 +134,7 @@ GEM coffee-script-source (1.12.2) concurrent-ruby (1.3.6) connection_pool (3.0.2) - console (1.35.1) + console (1.36.0) fiber-annotation fiber-local (~> 1.1) json @@ -201,7 +201,7 @@ GEM jbuilder (2.12.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.19.5) + json (2.19.8) jwt (3.2.0) base64 language_server-protocol (3.17.0.5) @@ -346,14 +346,14 @@ GEM erb psych (>= 4.0.0) tsort - react_on_rails (16.7.0.rc.3) + react_on_rails (17.0.0.rc.1) addressable connection_pool execjs (~> 2.5) rails (>= 5.2) rainbow (~> 3.0) shakapacker (>= 6.0) - react_on_rails_pro (16.7.0.rc.3) + react_on_rails_pro (17.0.0.rc.1) addressable async (>= 2.29) async-http (~> 0.95) @@ -361,7 +361,7 @@ GEM io-endpoint (~> 0.17.0) jwt (>= 2.5, < 4) rainbow - react_on_rails (= 16.7.0.rc.3) + react_on_rails (= 17.0.0.rc.1) redcarpet (3.6.0) redis (5.3.0) redis-client (>= 0.22.0) @@ -505,7 +505,7 @@ GEM bindex (>= 0.4.0) railties (>= 6.0.0) websocket (1.2.10) - websocket-driver (0.8.0) + websocket-driver (0.8.1) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -549,7 +549,8 @@ DEPENDENCIES rails-html-sanitizer rails_best_practices rainbow - react_on_rails_pro (= 16.7.0.rc.3) + react_on_rails (= 17.0.0.rc.1) + react_on_rails_pro (= 17.0.0.rc.1) redcarpet redis (~> 5.0) rspec-rails (~> 6.0.0) diff --git a/README.md b/README.md index 1e5cfaa4..5a3dc7ff 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ShakaCode recently migrated [HiChee.com](https://hichee.com) to Control Plane, resulting in a two-thirds reduction in server hosting costs! -See doc in [./.controlplane/readme.md](./.controlplane/readme.md) for how to easily deploy this app to Control Plane. +See [./.controlplane/readme.md](./.controlplane/readme.md) for local `cpflow` setup plus the shared `cpflow-*` GitHub Actions flow for review apps, automatic staging deploys, and manual promotion to production. The instructions leverage the `cpflow` CLI, with source code and many more tips on how to migrate from Heroku to Control Plane in https://github.com/shakacode/heroku-to-control-plane. @@ -184,9 +184,12 @@ assets_bundler: rspack ### Version Targets -- `react_on_rails_pro` gem: `16.7.0.rc.3` -- `react-on-rails-pro` npm package: `16.7.0-rc.3` -- `react-on-rails-pro-node-renderer` npm package: `16.7.0-rc.3` +- `react_on_rails` gem: `17.0.0.rc.1` +- `react-on-rails` npm package: `17.0.0-rc.1` +- `react_on_rails_pro` gem: `17.0.0.rc.1` +- `react-on-rails-pro` npm package: `17.0.0-rc.1` +- `react-on-rails-pro-node-renderer` npm package: `17.0.0-rc.1` +- `react-on-rails-rsc` npm package: `19.0.5-rc.5` - `shakapacker` gem/npm package: `10.1.0` - `@rspack/core` and `@rspack/cli`: `2.0.0-beta.7` - `react`: `~19.0.4` (minimum for React Server Components) diff --git a/client/app/bundles/comments/components/CommentBox/CommentList/CommentList.spec.jsx b/client/app/bundles/comments/components/CommentBox/CommentList/CommentList.spec.jsx index f6aa3121..b1a095b2 100644 --- a/client/app/bundles/comments/components/CommentBox/CommentList/CommentList.spec.jsx +++ b/client/app/bundles/comments/components/CommentBox/CommentList/CommentList.spec.jsx @@ -27,9 +27,7 @@ describe('CommentList', () => { ); it('renders a list of Comments in normal order', () => { - render( - , - ); + render(); // Verify both authors are rendered in order expect(screen.getByText('Frank')).toBeInTheDocument(); diff --git a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx index cac19b8a..ae242b80 100644 --- a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx +++ b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx @@ -1,4 +1,5 @@ /* eslint-disable max-classes-per-file */ + 'use client'; import React from 'react'; diff --git a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx index b2ee2c5b..1be88de3 100644 --- a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -8,22 +8,22 @@ import { useRSC } from 'react-on-rails-pro/RSCProvider'; // Same shape and dimensions as the rendered LiveActivity card. Local Suspense // fallback prevents the RSCRoute suspension from bubbling to an outer // boundary, which would collapse the whole page during in-flight fetches. -const ActivityCardSkeleton = () => ( -
-
- {['Server Time', 'Free RAM', 'Uptime (hrs)'].map((label) => ( -
-
- {label} +function ActivityCardSkeleton() { + return ( +
+
+ {['Server Time', 'Free RAM', 'Uptime (hrs)'].map((label) => ( +
+
{label}
+
-
-
- ))} + ))} +
-
-); + ); +} -const LiveActivityRefresher = () => { +function LiveActivityRefresher() { const [refreshKey, setRefreshKey] = useState(0); const [simulateError, setSimulateError] = useState(false); const { refetchComponent } = useRSC(); @@ -94,6 +94,6 @@ const LiveActivityRefresher = () => {
); -}; +} export default LiveActivityRefresher; diff --git a/client/app/bundles/server-components/components/ServerInfo.jsx b/client/app/bundles/server-components/components/ServerInfo.jsx index e09fa1d9..b788adea 100644 --- a/client/app/bundles/server-components/components/ServerInfo.jsx +++ b/client/app/bundles/server-components/components/ServerInfo.jsx @@ -33,15 +33,18 @@ function ServerInfo() { return (

- This data comes from the Node.js os module - — it runs only on the server. The lodash library - used to format it never reaches the browser. + This data comes from the Node.js os module — it + runs only on the server. The lodash library used + to format it never reaches the browser.

{grouped.map((group) => (
k).join('-')} className="space-y-1"> {group.map(([key, value]) => ( -
+
{labels[key] || key} {value}
diff --git a/client/app/bundles/server-components/components/TogglePanel.jsx b/client/app/bundles/server-components/components/TogglePanel.jsx index f5a38a9e..3c230e45 100644 --- a/client/app/bundles/server-components/components/TogglePanel.jsx +++ b/client/app/bundles/server-components/components/TogglePanel.jsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; -const TogglePanel = ({ title, children }) => { +function TogglePanel({ title, children }) { const [isOpen, setIsOpen] = useState(false); return ( @@ -22,13 +22,9 @@ const TogglePanel = ({ title, children }) => { - {isOpen && ( -
- {children} -
- )} + {isOpen &&
{children}
}
); -}; +} export default TogglePanel; diff --git a/client/app/bundles/server-components/ror_components/LiveActivity.jsx b/client/app/bundles/server-components/ror_components/LiveActivity.jsx index a76f7de3..9c52a794 100644 --- a/client/app/bundles/server-components/ror_components/LiveActivity.jsx +++ b/client/app/bundles/server-components/ror_components/LiveActivity.jsx @@ -24,21 +24,15 @@ async function LiveActivity({ simulateError = false }) {
-
- Server Time -
+
Server Time
{stats.serverTime}
-
- Free RAM -
+
Free RAM
{stats.freeMemoryMB} MB
-
- Uptime (hrs) -
+
Uptime (hrs)
{stats.uptimeHours}
diff --git a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx index e6b8df84..1aa636f6 100644 --- a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx +++ b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx @@ -8,17 +8,15 @@ import CommentsFeed from '../components/CommentsFeed'; import TogglePanel from '../components/TogglePanel'; import LiveActivityRefresher from '../components/LiveActivityRefresher'; -const ServerComponentsPage = ({ comments = [] }) => { +function ServerComponentsPage({ comments = [] }) { return (
-

- React Server Components Demo -

+

React Server Components Demo

- This page is rendered using React Server Components with React on Rails Pro. - Server components run on the server and stream their output to the client, keeping - heavy dependencies out of the browser bundle entirely. + This page is rendered using React Server Components with React on Rails Pro. Server + components run on the server and stream their output to the client, keeping heavy dependencies out + of the browser bundle entirely.

@@ -45,12 +43,14 @@ const ServerComponentsPage = ({ comments = [] }) => {

- This toggle is a 'use client' component, meaning it ships JavaScript - to the browser for interactivity. But the content inside is rendered on the server - and passed as children — a key RSC pattern called the donut pattern. + This toggle is a 'use client' component, meaning it ships JavaScript to + the browser for interactivity. But the content inside is rendered on the server and passed as + children — a key RSC pattern called the donut pattern.

    -
  • The TogglePanel wrapper runs on the client (handles click events)
  • +
  • + The TogglePanel wrapper runs on the client (handles click events) +
  • The children content is rendered on the server (no JS cost)
  • Heavy libraries used by server components never reach the browser
@@ -67,12 +67,11 @@ const ServerComponentsPage = ({ comments = [] }) => {

- Click Refresh to fetch a new RSC payload — the server re-renders - this section and streams the result back, no client-side JSON parsing or loading - state plumbing. Click Simulate Error to make the server component - throw; the failure surfaces as ServerComponentFetchError and is - caught by <ErrorBoundary>, which renders a Retry button that - calls refetchComponent with corrected props. + Click Refresh to fetch a new RSC payload — the server re-renders this section and + streams the result back, no client-side JSON parsing or loading state plumbing. Click{' '} + Simulate Error to make the server component throw; the failure surfaces as{' '} + ServerComponentFetchError and is caught by <ErrorBoundary>, which + renders a Retry button that calls refetchComponent with corrected props.

@@ -86,9 +85,9 @@ const ServerComponentsPage = ({ comments = [] }) => {

- Comments come from the Rails controller as props — the canonical React on Rails Pro - pattern. The page shell renders immediately while this section streams in - progressively as Suspense boundaries resolve. + Comments come from the Rails controller as props — the canonical React on Rails Pro pattern. The + page shell renders immediately while this section streams in progressively as Suspense boundaries + resolve.

{ {/* Architecture explanation */}
-

- What makes this different? -

+

What makes this different?

Smaller Client Bundle

@@ -122,22 +119,22 @@ const ServerComponentsPage = ({ comments = [] }) => {

Direct Data Access

- Server components fetch data by calling your Rails API internally — no - client-side fetch waterfalls or loading spinners for initial data. + Server components fetch data by calling your Rails API internally — no client-side fetch + waterfalls or loading spinners for initial data.

Progressive Streaming

- The page shell renders instantly. Async components (like the comments feed) - stream in as their data resolves, with Suspense boundaries showing fallbacks. + The page shell renders instantly. Async components (like the comments feed) stream in as their + data resolves, with Suspense boundaries showing fallbacks.

Selective Hydration

- Only client components (like the toggle above) receive JavaScript. - Everything else is pure HTML — zero hydration cost. + Only client components (like the toggle above) receive JavaScript. Everything else is pure + HTML — zero hydration cost.

@@ -145,6 +142,6 @@ const ServerComponentsPage = ({ comments = [] }) => {
); -}; +} export default ServerComponentsPage; diff --git a/client/app/packs/stores-registration.js b/client/app/packs/stores-registration.js index a069ac62..9eb06333 100644 --- a/client/app/packs/stores-registration.js +++ b/client/app/packs/stores-registration.js @@ -1,4 +1,5 @@ // 'use client' keeps this pack and its store imports out of the RSC bundle. + 'use client'; import ReactOnRails from 'react-on-rails-pro'; diff --git a/config/initializers/react_on_rails.rb b/config/initializers/react_on_rails.rb index b62aea27..0ee3338c 100644 --- a/config/initializers/react_on_rails.rb +++ b/config/initializers/react_on_rails.rb @@ -7,10 +7,12 @@ config.auto_load_bundle = true # Build commands - # Note: react_on_rails:assets:webpack (run by assets:precompile) depends on react_on_rails:locale, - # so locale generation happens automatically. We just need rescript to run first. - config.build_test_command = "yarn res:build && RAILS_ENV=test bin/shakapacker" - config.build_production_command = "yarn res:build && RAILS_ENV=production NODE_ENV=production bin/shakapacker" + # Direct shakapacker invocations need generated ReScript and locale files first. + config.build_test_command = + "yarn res:build && RAILS_ENV=test bin/rails react_on_rails:locale && RAILS_ENV=test bin/shakapacker" + config.build_production_command = + "yarn res:build && RAILS_ENV=production NODE_ENV=production bin/rails react_on_rails:locale && " \ + "RAILS_ENV=production NODE_ENV=production bin/shakapacker" # This is the file used for server rendering of React when using `(prerender: true)` # If you are never using server rendering, you may set this to "". diff --git a/config/webpack/commonWebpackConfig.js b/config/webpack/commonWebpackConfig.js index 5c14f01f..d9af60ba 100644 --- a/config/webpack/commonWebpackConfig.js +++ b/config/webpack/commonWebpackConfig.js @@ -2,12 +2,14 @@ // https://github.com/shakacode/react_on_rails_tutorial_with_ssr_and_hmr_fast_refresh/blob/master/config/webpack/commonWebpackConfig.js // Common configuration applying to client and server configuration +const path = require('path'); const { generateWebpackConfig, merge } = require('shakapacker'); const commonOptions = { resolve: { // Add .res.js extension for ReScript-compiled modules (modern ReScript convention) extensions: ['.css', '.ts', '.tsx', '.res.js'], + modules: [path.resolve(__dirname, '../../client/app'), 'node_modules'], // Shim for third-party packages (notably rescript-react-on-rails) that import // 'react-on-rails' directly and can't be source-rewritten to react-on-rails-pro. // Without this, Pro and core coexist in the bundle and trigger the runtime error @@ -113,4 +115,3 @@ const commonWebpackConfig = () => { }; module.exports = commonWebpackConfig; - diff --git a/package.json b/package.json index 1020a881..a6219a8e 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,13 @@ "res:dev": "yarn res:clean && rescript build -w", "res:watch": "rescript build -w", "res:build": "yarn res:clean && rescript build", - "lint:eslint": "yarn eslint client --ext \".js,.jsx,.ts\"", + "lint:eslint": "yarn eslint client --ext \".js,.jsx,.ts\" --ignore-pattern \"client/app/generated/**\" --ignore-pattern \"client/app/packs/generated/**\" --ignore-pattern \"client/app/libs/i18n/*.js\" --ignore-pattern \"client/app/packs/server-bundle.js\"", "lint:prettier": "yarn prettier \"**/*.@(js|jsx)\" --list-different", "lint": " yarn lint:eslint --fix && yarn lint:prettier --w", "test": "yarn build:test && yarn lint && yarn jest", "test:client": "yarn jest", - "build:test": "rm -rf public/packs-test && RAILS_ENV=test NODE_ENV=test bin/shakapacker", - "build:dev": "rm -rf public/packs && RAILS_ENV=development NODE_ENV=development bin/shakapacker", + "build:test": "rm -rf public/packs-test && yarn res:build && RAILS_ENV=test NODE_ENV=test bundle exec rails react_on_rails:locale && RAILS_ENV=test NODE_ENV=test bundle exec rake react_on_rails:generate_packs && RAILS_ENV=test NODE_ENV=test bin/shakapacker", + "build:dev": "rm -rf public/packs && yarn res:build && RAILS_ENV=development NODE_ENV=development bundle exec rails react_on_rails:locale && RAILS_ENV=development NODE_ENV=development bin/shakapacker", "build:clean": "rm -rf public/packs || true", "node-renderer": "node renderer/node-renderer.js" }, @@ -45,10 +45,10 @@ "@hotwired/stimulus-webpack-helpers": "^1.0.1", "@hotwired/turbo-rails": "^7.3.0", "@rails/actioncable": "7.0.5", - "@rspack/cli": "2.0.0-beta.7", - "@rspack/core": "2.0.0-beta.7", "@rescript/core": "^0.5.0", "@rescript/react": "^0.11.0", + "@rspack/cli": "2.0.0-beta.7", + "@rspack/core": "2.0.0-beta.7", "@swc/core": "^1.13.5", "ajv": "^8.17.1", "autoprefixer": "^10.4.14", @@ -82,9 +82,10 @@ "react-dom": "~19.0.4", "react-error-boundary": "^4.1.2", "react-intl": "^6.4.4", - "react-on-rails-pro": "16.7.0-rc.3", - "react-on-rails-pro-node-renderer": "16.7.0-rc.3", - "react-on-rails-rsc": "19.0.4", + "react-on-rails": "17.0.0-rc.1", + "react-on-rails-pro": "17.0.0-rc.1", + "react-on-rails-pro-node-renderer": "17.0.0-rc.1", + "react-on-rails-rsc": "19.0.5-rc.5", "react-redux": "^8.1.0", "react-router": "^6.13.0", "react-router-dom": "^6.13.0", diff --git a/yarn.lock b/yarn.lock index 6a259021..d51d8272 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8759,10 +8759,10 @@ react-is@^18.0.0, react-is@^18.3.1: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-on-rails-pro-node-renderer@16.7.0-rc.3: - version "16.7.0-rc.3" - resolved "https://registry.npmjs.org/react-on-rails-pro-node-renderer/-/react-on-rails-pro-node-renderer-16.7.0-rc.3.tgz#2a2e20ab2815f8a03214ce5d42ac498420c08d3f" - integrity sha512-k8fyz2BZBW6YlVAt4BP7WQ+J8czAq0p27NDAlcasBNk9C1fttP2UjLY7n9WpkCJPW6Uc4B74dNwzmfcUDYG1FQ== +react-on-rails-pro-node-renderer@17.0.0-rc.1: + version "17.0.0-rc.1" + resolved "https://registry.npmjs.org/react-on-rails-pro-node-renderer/-/react-on-rails-pro-node-renderer-17.0.0-rc.1.tgz#817b3b6e13ffa52e50bc0830eb1136027ea301f2" + integrity sha512-suh7KVh7zPG0/pctY/BBRlWOV0hucR6elKlpZ6Pgy5lF1cAL3fqgv+hZco6Wr94ZfrfG64UQ5zFdqlnpT7fkMA== dependencies: "@fastify/formbody" "^7.4.0 || ^8.0.2" "@fastify/multipart" "^8.3.1 || ^9.0.3" @@ -8772,26 +8772,26 @@ react-on-rails-pro-node-renderer@16.7.0-rc.3: lockfile "^1.0.4" pino "^9.14.0 || ^10.1.0" -react-on-rails-pro@16.7.0-rc.3: - version "16.7.0-rc.3" - resolved "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-16.7.0-rc.3.tgz#d30ec0d27ab25a5d1c4b817b654192a3a7486cee" - integrity sha512-oJO7/26c+UQwd2/YbPkXqQgsGhXc1HE1iOwhCJijs4iKvQkOFb8Mm6r1BLrb3Kt/28n9PChZq0+XfbMDPvCuLg== +react-on-rails-pro@17.0.0-rc.1: + version "17.0.0-rc.1" + resolved "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-17.0.0-rc.1.tgz#b06d4155baeaf0e5fb26024dc5c55b6063163f91" + integrity sha512-O2c1PFbcdRgpp4LbtxlMZSCZPXt+DmPV/ugAlXSZWDKK9bSuGZpxHjgpFm2lWlEFkXy5Lh4bhsBRaA42K9MEFw== dependencies: - react-on-rails "16.7.0-rc.3" + react-on-rails "17.0.0-rc.1" -react-on-rails-rsc@19.0.4: - version "19.0.4" - resolved "https://registry.npmjs.org/react-on-rails-rsc/-/react-on-rails-rsc-19.0.4.tgz#a605fbaa82a0bece504de1ef0b8d5c6fae5d6be3" - integrity sha512-KtHYz0opcXJk+Zw5aabjNiaszzpTdFgs1YfSLIZJSzU5SpddUw7b08epkxeJq/TCpR60Q9vsjxX3Q3OWJ97tMg== +react-on-rails-rsc@19.0.5-rc.5: + version "19.0.5-rc.5" + resolved "https://registry.npmjs.org/react-on-rails-rsc/-/react-on-rails-rsc-19.0.5-rc.5.tgz#2dc1dd30764be9b879a951ecc4d2e921af95952e" + integrity sha512-NTe3g34iR0ya8XFUpwbqE37ff4x5YGZEGowWGbY4o/wqNPykICDH5Nsd7MjqUSwun5NYqI92FHKHSEhpHgPq2A== dependencies: acorn-loose "^8.3.0" neo-async "^2.6.1" webpack-sources "^3.2.0" -react-on-rails@16.7.0-rc.3: - version "16.7.0-rc.3" - resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-16.7.0-rc.3.tgz#07ffd98bb54c11b471616f2091165983e9c4d967" - integrity sha512-je/0D0w0rHKW5eVwqajqci4F3IGe3O7jfzJcQ7Lv/WrHUqy+QcTkwYxsnrVfsk+8SkMlkFGVCmuUjcuVWJncLg== +react-on-rails@17.0.0-rc.1: + version "17.0.0-rc.1" + resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-17.0.0-rc.1.tgz#5c13081957612e3cb176674217efbc6785ecb362" + integrity sha512-TLR+LdpdefizXIDR7PUnXL3bUa38hex24AiuMxrrdY2w2ju8GGAKFNZM/6mTc+FatO1Y1MEoPnsRuabtXt8nVQ== react-proxy@^1.1.7: version "1.1.8"