diff --git a/.lychee.toml b/.lychee.toml index c204b15a5e..ab3c31e77c 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -99,6 +99,8 @@ exclude = [ # the changelog is committed). Exclude all compare links since they're # auto-generated by changelog tooling. '^https://github\.com/shakacode/react_on_rails/compare/', + '^https://github\.com/shakacode/react_on_rails/pull/2280$', # Intermittent 502 from GitHub PR page in CI + '^https://github\.com/shakacode/shakapacker/blob/cdf32835d3e0949952b8b4b53063807f714f9b24/package/environments/base\.js(#.*)?$', # Intermittent 502 from GitHub blob view in CI # ============================================================================ # DELETED GITHUB USER ACCOUNTS diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be6ea3afc..e654bfa6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Stable release — no changes from 16.5.0.rc.0. - **[Pro]** **Minimum `async` gem version bumped to 2.29**: The streaming helper now requires `async >= 2.29` (previously `>= 2.6`) due to the migration from `Async::Variable` to `Async::Promise`. If your Gemfile pins the `async` gem below 2.29, you will need to update it before upgrading React on Rails Pro. Run `bundle update async` to pick up the new minimum. [PR 2832](https://github.com/shakacode/react_on_rails/pull/2832) by [justin808](https://github.com/justin808). +- **[Pro]** **Node renderer upload protocol now requires `bundle_` form keys**: The `/upload-assets` endpoint now derives bundle destinations from `bundle_` fields and no longer accepts `targetBundles`-only payloads from older clients. Upgrade the React on Rails Pro gem and node renderer together so both sides speak the same upload protocol. #### Changed diff --git a/docs/async-props/README.md b/docs/async-props/README.md new file mode 100644 index 0000000000..c0ad649b38 --- /dev/null +++ b/docs/async-props/README.md @@ -0,0 +1,125 @@ +# Async Props: Streaming Server-Side Rendering + +Async Props is a React on Rails Pro feature that enables **streaming server-side rendering** with progressive hydration. Instead of waiting for all data to load before sending any HTML to the browser, Async Props streams the page shell immediately while data fetches happen in parallel. + +## The Problem with Traditional SSR + +In traditional SSR, the entire page must wait for **all** data before anything is sent to the browser: + +![Traditional SSR vs Streaming SSR](./images/traditional-vs-streaming-ssr.svg) + +This creates a poor user experience: +- **Long Time to First Byte (TTFB)**: Users stare at a blank screen +- **Sequential data fetching**: Each data source blocks the next +- **All-or-nothing rendering**: No content until everything is ready + +## How Async Props Solves This + +Async Props uses React 18's streaming capabilities to render content progressively: + +![Timeline Comparison](./images/timeline-comparison.svg) + +### Key Benefits + +| Metric | Traditional SSR | Async Props | +|--------|-----------------|-------------| +| Time to First Byte | 1800ms | **50ms** | +| Time to Interactive | 1800ms | **50ms** | +| Data Fetching | Sequential | **Parallel** | +| User Perception | Slow | **Fast** | + +## Progressive Loading in Action + +Watch how content loads progressively with Async Props: + +![Progressive Loading Sequence](./images/progressive-loading-sequence.svg) + +1. **Stage 1 (50ms)**: Shell renders with skeleton loaders - page is already interactive! +2. **Stage 2 (500ms)**: First data arrives, Users section hydrates +3. **Stage 3 (900ms)**: Remaining data arrives, page fully loaded + +## Architecture Overview + +Async Props uses NDJSON streaming between Rails and the Node renderer: + +![Architecture Flow](./images/architecture-flow.svg) + +### How It Works + +1. **Rails view helper** defines async props using `stream_react_component_with_async_props` +2. **NDJSON Stream** opens between Rails and Node renderer +3. **Shell HTML** is sent to browser immediately +4. **Data fetches** happen in parallel on the Rails side +5. **Resolved props** stream to Node as they complete +6. **React hydrates** each section as its data arrives + +## Quick Start + +### 1. Define Async Props in Your View + +```erb +<%= stream_react_component_with_async_props("Dashboard", props: { title: "My Dashboard" }) do + { + users: User.active.limit(10), + posts: Post.recent.limit(5) + } +end %> +``` + +### 2. Use Suspense in Your Component + +```tsx +import type { WithAsyncProps } from 'react-on-rails'; +import React, { Suspense } from 'react'; + +type AsyncProps = { + users: User[]; + posts: Post[]; +}; + +type SyncProps = { + title: string; +}; + +async function Dashboard({ title, getReactOnRailsAsyncProp }: WithAsyncProps) { + const users = await getReactOnRailsAsyncProp('users'); + const posts = await getReactOnRailsAsyncProp('posts'); + + return ( +
+

{title}

+ + }> + + + + }> + + +
+ ); +} +``` + +### 3. That's It! + +The shell with skeleton loaders renders immediately. As each async prop resolves, React hydrates that section automatically. + +## When to Use Async Props + +**Use Async Props when:** +- You have slow database queries or API calls +- Multiple independent data sources +- Pages with distinct loading sections +- SEO is important (full SSR, not client-side fetch) + +**Consider alternatives when:** +- Data fetches are already fast (<100ms) +- Single data source with no parallelization opportunity +- Static pages with no dynamic data + +## Learn More + +- [How Async Props Works](./how-it-works.md) - Deep dive into the streaming architecture +- [API Reference](./api-reference.md) - Complete configuration options +- [Advanced Usage](./advanced-usage.md) - Error handling, caching, and optimization diff --git a/docs/async-props/advanced-usage.md b/docs/async-props/advanced-usage.md new file mode 100644 index 0000000000..68c14ae758 --- /dev/null +++ b/docs/async-props/advanced-usage.md @@ -0,0 +1,357 @@ +# Advanced Async Props Usage + +Advanced patterns, error handling, and optimization techniques for Async Props. + +## Error Boundaries + +Wrap async components with error boundaries to gracefully handle failures: + +```tsx +import React, { Component, Suspense } from 'react'; + +class AsyncErrorBoundary extends Component { + state = { hasError: false, error: null }; + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return
Failed to load: {this.state.error.message}
; + } + return this.props.children; + } +} + +// Usage +function Dashboard() { + return ( + + }> + + + + ); +} +``` + +## Nested Suspense Boundaries + +Create fine-grained loading states with nested boundaries: + +```tsx +function Dashboard() { + return ( +
+ {/* Header loads first */} + }> +
+ + +
+ {/* Sidebar and main content load independently */} + }> + + + +
+ {/* Nested: Stats load before chart */} + }> + + }> + + + +
+
+
+ ); +} +``` + +## Parallel vs Sequential Loading + +### Parallel (Recommended) + +All async props fetch simultaneously: + +```ruby +<%= stream_react_component_with_async_props("Dashboard") do + { + users: User.active, # Starts immediately + posts: Post.recent # Starts immediately + } +end %> +``` + +### Sequential (When Needed) + +Chain dependent data: + +```ruby +<%= stream_react_component_with_async_props("Profile") do + user = User.find(params[:id]) + { + user: user, + posts: user.posts.recent # Depends on user + } +end %> +``` + +## Error Handling + +Async Props does not expose per-prop `timeout:` or `on_error:` options yet. If a fetch can fail, handle the error inside the async block and return a fallback value that your component can render. + +### Fallback Values + +```ruby +users: begin + ExternalService.users +rescue => e + Rails.logger.warn("users async prop failed: #{e.message}") + [] +end +``` + +### React-side Fallback + +```tsx +async function UsersList({ getReactOnRailsAsyncProp }) { + const usersResult = await getReactOnRailsAsyncProp('users'); + + if (usersResult.error) { + return ; + } + + return
    {usersResult.map(...)}
; +} +``` + +## Caching Strategies + +### Rails-side Caching + +```ruby +<%= stream_react_component_with_async_props("Dashboard") do + { + users: Rails.cache.fetch("active_users", expires_in: 5.minutes) do + User.active.to_a + end + } +end %> +``` + +### Component-level Caching + +```ruby +<%= stream_react_component_with_async_props("Dashboard", props: { title: "Dashboard" }) do + { + users: User.active + } +end %> +``` + +## Optimizing Skeleton Loaders + +### Match Content Dimensions + +```tsx +// Bad: Generic skeleton +
+ +// Good: Matches actual content +
{/* Card size */} +``` + +### Animate Thoughtfully + +```css +.skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s ease-in-out infinite; +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +``` + +## Debugging Async Props + +### Enable Debug Mode + +```ruby +# config/environments/development.rb +ReactOnRailsPro.configure do |config| + config.tracing = true +end +``` + +### Console Logging + +```javascript +// In your React component +async function UsersList({ getReactOnRailsAsyncProp }) { + const users = await getReactOnRailsAsyncProp('users'); + console.log('[AsyncProp] users resolved:', users); + return ...; +} +``` + +### React DevTools + +1. Open React DevTools +2. Find Suspense components +3. Check their "fallback" and "children" states +4. Monitor hydration progress + +## Performance Monitoring + +### Track Async Prop Timing + +```ruby +users: begin + start = Time.now + result = User.active.to_a + Rails.logger.info "[AsyncProp] users: #{(Time.now - start) * 1000}ms" + result +end +``` + +### Server Timing Headers + +```ruby +# In your controller +def show + timing_data = {} + + stream_react_component_with_async_props("Dashboard") do + { + users: begin + start = Time.now + result = User.active + timing_data[:users] = Time.now - start + result + end + } + end + + response.headers['Server-Timing'] = timing_data.map { |k, v| + "#{k};dur=#{(v * 1000).round}" + }.join(', ') +end +``` + +## Testing Async Props + +### RSpec Integration Tests + +```ruby +RSpec.describe "Dashboard", type: :system do + it "loads users progressively" do + visit dashboard_path + + # Shell renders immediately + expect(page).to have_css('.dashboard-header') + expect(page).to have_css('.users-skeleton') + + # Wait for async content + expect(page).to have_css('.users-list', wait: 10) + expect(page).not_to have_css('.users-skeleton') + end +end +``` + +### Jest Component Tests + +```tsx +import { render, waitFor } from '@testing-library/react'; +import { AsyncPropsProvider } from '@react-on-rails-pro/core'; + +test('renders with async props', async () => { + const mockUsers = [{ id: 1, name: 'Alice' }]; + + const { getByText, queryByText } = render( + + Loading...
}> + + + + ); + + await waitFor(() => { + expect(getByText('Alice')).toBeInTheDocument(); + expect(queryByText('Loading...')).not.toBeInTheDocument(); + }); +}); +``` + +## Common Patterns + +### Read async props in an async Server Component + +```tsx +async function UsersList({ getReactOnRailsAsyncProp }) { + const users = await getReactOnRailsAsyncProp('users'); + return ; +} +``` + +There is no `useAsyncProp` hook in React on Rails Pro. If a Client Component needs to manage the data after hydration, pass the resolved value down from a Server Component and seed local state from that value. + +## Migration from Traditional SSR + +### Before (Traditional) + +```ruby +# Controller +def show + @users = User.active + @posts = Post.recent +end +``` + +```erb + +<%= react_component("Dashboard", props: { users: @users, posts: @posts }) %> +``` + +### After (Async Props) + +```ruby +# Controller +<%= stream_react_component_with_async_props("Dashboard") do + { + users: User.active, + posts: Post.recent + } +end %> +``` + +```tsx +// Component (add Suspense) +async function Dashboard({ getReactOnRailsAsyncProp }) { + const users = await getReactOnRailsAsyncProp('users'); + const posts = await getReactOnRailsAsyncProp('posts'); + + return ( + <> + }> + + + }> + + + + ); +} +``` + +## Related Documentation + +- [Async Props Overview](./README.md) +- [How It Works](./how-it-works.md) +- [API Reference](./api-reference.md) diff --git a/docs/async-props/api-reference.md b/docs/async-props/api-reference.md new file mode 100644 index 0000000000..08f07b01e1 --- /dev/null +++ b/docs/async-props/api-reference.md @@ -0,0 +1,129 @@ +# Async Props API Reference + +Complete reference for the Async Props API. + +## View Helpers + +### `stream_react_component_with_async_props(component_name, options = {}, &props_block)` + +Streams a React component and exposes async props to the component via `getReactOnRailsAsyncProp`. +The block should return a hash of async props to evaluate and stream. + +```erb +<%= stream_react_component_with_async_props("Dashboard", props: { title: "Dashboard" }) do + { + users: User.active.limit(10), + posts: Post.recent.limit(5) + } +end %> +``` + +#### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `component_name` | String | Name of the registered React component | +| `options` | Hash | Same options as `stream_react_component` (for example `props`, `dom_id`, `html_options`, `trace`) | +| `props_block` | Proc | Returns a hash of async props to stream | + +> **Status:** Per-prop `timeout:` and `on_error:` are not implemented in the current release. Handle those concerns inside the block or with the global `ssr_timeout`. + +### `rsc_payload_react_component_with_async_props(component_name, options = {}, &props_block)` + +Same async-prop block contract, but renders the RSC payload stream instead of the HTML stream. + +```erb +<%= rsc_payload_react_component_with_async_props("Dashboard") do + { users: User.active.limit(10) } +end %> +``` + +## React Component Props + +### `getReactOnRailsAsyncProp` + +Async prop accessor injected into components rendered through the async-props helpers. The function returns the same Promise on repeated calls for the same prop name. + +```tsx +async function UsersList({ getReactOnRailsAsyncProp }) { + const users = await getReactOnRailsAsyncProp('users'); + + return ( +
    + {users.map((user) => ( +
  • {user.name}
  • + ))} +
+ ); +} +``` + +#### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `propName` | string | Name of the async prop to retrieve | + +#### Returns + +A Promise for the resolved value of the async prop. Repeated calls share the same underlying promise so React can suspend and resume consistently. + +## Configuration + +```ruby +# config/initializers/react_on_rails_pro.rb +ReactOnRailsPro.configure do |config| + config.server_renderer = "NodeRenderer" + config.enable_rsc_support = true + config.tracing = true + config.ssr_timeout = 5 + config.renderer_http_pool_size = 10 + config.renderer_http_pool_timeout = 5 + config.renderer_http_pool_warn_timeout = 0.25 +end +``` + +The following options are **not** part of the current release and should not be documented as active APIs: `node_renderer_timeout`, `async_props_default_timeout`, `async_props_parallel_limit`, and `trace_async_props`. + +## NDJSON Protocol + +The async-props pipeline uses NDJSON between Rails and the Node renderer. + +### Request Flow (Rails → Node) + +```json +{"renderingRequest": "{\"componentName\":\"App\",\"props\":{...}}"} +{"resolvedAsyncProp": {"propName": "users", "value": [{"id": 1, "name": "Alice"}]}} +{"resolvedAsyncProp": {"propName": "posts", "value": [{"id": 1, "title": "Hello"}]}} +{"requestEnded": true} +``` + +### Response Flow (Node → Rails) + +```json +{"html": "..."} +{"consoleReplayScript": "