Testing a Server-Side Rendered UI Driven by a gRPC-Orchestrated Saga


The technical pain point emerged not from a single failed deployment, but from a slow erosion of user confidence. We were building a multi-service resource provisioning platform—think creating a complex cloud environment involving a database, a cache, and a compute instance, each managed by a separate microservice. The backend team correctly chose a Saga pattern to orchestrate this multi-step, long-running transaction. If the database provisioned successfully but the cache failed, the Saga would trigger a compensating action to de-provision the database. This worked reliably on the backend. The frontend, however, was a mess.

Our first attempt was a simple React SPA that polled a REST endpoint every few seconds. This led to a cascade of problems in a real-world project. The UI state was always lagging behind the true state of the backend transaction. A failed step might not be reflected for several seconds, leading to confused users trying to interact with a zombie process. The network chatter from constant polling was inefficient, and the client-side logic to manage the polling, retries, and state transitions was complex and brittle. Most critically, the initial page load was an empty spinner, which was terrible for perceived performance and made linking directly to an in-progress provisioning job impossible.

The initial concept for a v2 architecture was clear: we needed Server-Side Rendering (SSR) for the initial state and a real-time push mechanism for subsequent updates. This would solve the empty page load and the state lag. The technology selection process was driven by this requirement.

For the real-time communication, gRPC was the obvious choice over WebSockets. Our microservices were already communicating via gRPC, and its use of Protobuf ensured strong typing from the backend to the frontend. More importantly, gRPC’s native support for server-side streaming was a perfect fit for broadcasting the Saga’s state transitions. A single, long-lived connection is far more efficient than polling.

For the frontend framework, we settled on Next.js to handle the SSR. Its getServerSideProps function provided a clean entry point to fetch the initial state of a given Saga transaction before rendering the page on the server.

The final piece was testing. This new architecture, while powerful, presented a significant testing challenge. How do you write a reliable unit or integration test for a React component that receives initial state via SSR props and then immediately subscribes to a gRPC stream for subsequent updates? This is where React Testing Library came in. Its philosophy of testing behavior, not implementation, would be critical. We needed to simulate the entire lifecycle of the Saga from the perspective of the UI, from the initial server-rendered HTML to the final “completed” or “failed” state delivered over a mock stream.

Defining the Communication Contract

Before writing any client or server code, the gRPC contract is paramount. It defines the immutable boundary between the backend Saga orchestrator and the frontend consumer. A common mistake is to make this contract too chatty or too sparse. We needed a service that could provide the full state of a Saga on-demand (for SSR) and then stream subsequent atomic state changes.

Here is the Protobuf definition for our SagaTracker service.

// saga_tracker.proto
syntax = "proto3";

package saga;

service SagaTracker {
  // Unary RPC to get the current snapshot of a Saga.
  // Used by the SSR process to fetch the initial state.
  rpc GetSagaState(GetSagaStateRequest) returns (SagaStateResponse);

  // Server-streaming RPC to subscribe to real-time updates for a Saga.
  // Used by the client-side React component after hydration.
  rpc SubscribeToSagaUpdates(SubscribeToSagaUpdatesRequest) returns (stream SagaUpdate);
}

// -- Request/Response Messages for GetSagaState --

message GetSagaStateRequest {
  string saga_id = 1;
}

message SagaStateResponse {
  string saga_id = 1;
  SagaStatus status = 2;
  repeated SagaStep steps = 3;
  optional string error_message = 4;
}

// -- Request/Response Messages for SubscribeToSagaUpdates --

message SubscribeToSagaUpdatesRequest {
  string saga_id = 1;
}

// SagaUpdate is the message streamed from the server to the client.
// It represents a discrete change in the Saga's state.
message SagaUpdate {
  string saga_id = 1;

  oneof update_type {
    SagaStepUpdate step_update = 2;
    SagaStatusUpdate status_update = 3;
  }
}

message SagaStepUpdate {
  string step_name = 1;
  SagaStatus step_status = 2;
  optional string error_message = 3;
}

message SagaStatusUpdate {
  SagaStatus new_status = 1;
  optional string error_message = 2;
}

// -- Shared Data Structures --

message SagaStep {
  string name = 1;
  SagaStatus status = 2;
  int32 sequence = 3;
}

enum SagaStatus {
  SAGA_STATUS_UNSPECIFIED = 0;
  PENDING = 1;
  IN_PROGRESS = 2;
  COMPLETED = 3;
  FAILED = 4;
  COMPENSATING = 5;
  COMPENSATED = 6;
}

This contract is explicit. GetSagaState is a standard request-response call, perfect for the synchronous nature of getServerSideProps. SubscribeToSagaUpdates is a streaming RPC. The SagaUpdate message uses a oneof field, which is a clean way to model different types of events flowing through the same stream. This prevents us from having to create multiple streams or use generic JSON blobs.

The Backend Orchestrator Stub

For the purpose of this post, a full backend Saga implementation isn’t necessary. What’s important is a stub that can emulate the behavior our frontend will interact with. This Node.js gRPC server simulates a three-step Saga: “Provisioning Database”, “Provisioning Cache”, and “Provisioning Instance”. It will randomly decide whether the Saga succeeds or fails at the cache step.

// server/sagaOrchestrator.js
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const { v4: uuidv4 } = require('uuid');

const PROTO_PATH = './saga_tracker.proto';
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});
const sagaProto = grpc.loadPackageDefinition(packageDefinition).saga;

// In-memory store for active Sagas. In production, this would be Redis, a database, etc.
const sagas = new Map();
// In-memory store for active subscriptions (streams).
const subscriptions = new Map();

const SAGA_STATUS = {
  PENDING: 1,
  IN_PROGRESS: 2,
  COMPLETED: 3,
  FAILED: 4,
  COMPENSATING: 5,
  COMPENSATED: 6,
};

function createNewSaga() {
  const sagaId = uuidv4();
  const saga = {
    saga_id: sagaId,
    status: SAGA_STATUS.IN_PROGRESS,
    steps: [
      { name: 'Provisioning Database', status: SAGA_STATUS.IN_PROGRESS, sequence: 1 },
      { name: 'Provisioning Cache', status: SAGA_STATUS.PENDING, sequence: 2 },
      { name: 'Provisioning Instance', status: SAGA_STATUS.PENDING, sequence: 3 },
    ],
    errorMessage: null,
  };
  sagas.set(sagaId, saga);
  console.log(`[Orchestrator] Created and started Saga: ${sagaId}`);
  // Start the simulation
  simulateSagaExecution(sagaId);
  return saga;
}

// This function simulates the long-running Saga process.
async function simulateSagaExecution(sagaId) {
  const saga = sagas.get(sagaId);
  if (!saga) return;

  const steps = saga.steps.sort((a, b) => a.sequence - b.sequence);
  let hasFailed = false;

  for (let i = 0; i < steps.length; i++) {
    const step = steps[i];
    await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate work

    // Randomly fail the second step
    const shouldFail = step.name === 'Provisioning Cache' && Math.random() > 0.5;

    if (shouldFail) {
      step.status = SAGA_STATUS.FAILED;
      saga.status = SAGA_STATUS.FAILED;
      hasFailed = true;
      console.log(`[Orchestrator] Saga ${sagaId}, Step '${step.name}' FAILED`);
      
      publishUpdate(sagaId, { step_update: { step_name: step.name, step_status: SAGA_STATUS.FAILED, error_message: 'Cache timeout' } });
      publishUpdate(sagaId, { status_update: { new_status: SAGA_STATUS.FAILED, error_message: 'Failed at cache provisioning' } });
      break;
    } else {
      step.status = SAGA_STATUS.COMPLETED;
      console.log(`[Orchestrator] Saga ${sagaId}, Step '${step.name}' COMPLETED`);
      publishUpdate(sagaId, { step_update: { step_name: step.name, step_status: SAGA_STATUS.COMPLETED } });
    }
  }

  if (hasFailed) {
    // Simulate compensation
    saga.status = SAGA_STATUS.COMPENSATING;
    publishUpdate(sagaId, { status_update: { new_status: SAGA_STATUS.COMPENSATING } });
    
    for (let i = steps.length - 1; i >= 0; i--) {
        if (steps[i].status === SAGA_STATUS.COMPLETED) {
            await new Promise(resolve => setTimeout(resolve, 1000));
            steps[i].status = SAGA_STATUS.COMPENSATED;
            console.log(`[Orchestrator] Saga ${sagaId}, Step '${steps[i].name}' COMPENSATED`);
            publishUpdate(sagaId, { step_update: { step_name: steps[i].name, step_status: SAGA_STATUS.COMPENSATED } });
        }
    }
    
    saga.status = SAGA_STATUS.COMPENSATED;
    publishUpdate(sagaId, { status_update: { new_status: SAGA_STATUS.COMPENSATED } });

  } else {
    saga.status = SAGA_STATUS.COMPLETED;
    console.log(`[Orchestrator] Saga ${sagaId} COMPLETED successfully`);
    publishUpdate(sagaId, { status_update: { new_status: SAGA_STATUS.COMPLETED } });
  }
}

function publishUpdate(sagaId, update_type) {
    const update = { saga_id: sagaId, update_type };
    const sagaSubscriptions = subscriptions.get(sagaId);
    if (sagaSubscriptions) {
        sagaSubscriptions.forEach(call => {
            console.log(`[Orchestrator] Pushing update for ${sagaId} to a subscriber.`);
            call.write(update);
        });
    }
}

const server = new grpc.Server();
server.addService(sagaProto.SagaTracker.service, {
  GetSagaState: (call, callback) => {
    const sagaId = call.request.saga_id;
    const saga = sagas.get(sagaId);
    if (saga) {
      callback(null, saga);
    } else {
      // For simplicity, let's create one if it doesn't exist.
      // In a real app, you'd probably return NOT_FOUND.
      const newSaga = createNewSaga();
      callback(null, newSaga);
    }
  },
  SubscribeToSagaUpdates: (call) => {
    const sagaId = call.request.saga_id;
    console.log(`[Server] New subscription for Saga: ${sagaId}`);
    
    if (!subscriptions.has(sagaId)) {
      subscriptions.set(sagaId, new Set());
    }
    subscriptions.get(sagaId).add(call);

    call.on('cancelled', () => {
      console.log(`[Server] Subscription cancelled for Saga: ${sagaId}`);
      const sagaSubscriptions = subscriptions.get(sagaId);
      if (sagaSubscriptions) {
        sagaSubscriptions.delete(call);
        if (sagaSubscriptions.size === 0) {
          subscriptions.delete(sagaId);
        }
      }
    });
  },
});

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
  console.log('gRPC server running at http://0.0.0.0:50051');
  // Create a default saga for testing
  createNewSaga();
});

Frontend Implementation: SSR and Client-Side Hydration

Now for the Next.js side. We need a page that takes a sagaId from the URL, fetches its initial state on the server, and then subscribes to the gRPC stream on the client.

First, a small client wrapper for our gRPC service is needed. The pitfall here is that gRPC for the web (grpc-web) has a slightly different API than gRPC for Node.js (@grpc/grpc-js). We need a solution that works in both environments.

// lib/sagaClient.ts
// This file demonstrates the abstraction needed, though in a real project
// you might use different gRPC clients for browser vs. server.

import { credentials } from '@grpc/grpc-js';
import { SagaTrackerClient } from '../generated/saga_tracker_grpc_pb'; // Assuming generated code

// This client is for SERVER-SIDE use in `getServerSideProps`
export function getSagaTrackerServerClient() {
  return new SagaTrackerClient('localhost:50051', credentials.createInsecure());
}

// On the client-side, you'd typically use grpc-web's client.
// For this example, we'll focus on the architecture and testing.
// The component code will assume a client instance is provided.

Here’s the Next.js page component. It’s structured with a main component (SagaMonitorPage) and a custom hook (useSagaSubscription) to encapsulate the streaming logic.

// pages/saga/[sagaId].tsx
import { GetServerSideProps, NextPage } from 'next';
import { useEffect, useState } from 'react';
import { getSagaTrackerServerClient } from '../../lib/sagaClient'; // Server-side client
import { SagaStateResponse, SagaUpdate } from '../../generated/saga_tracker_pb'; // Generated types
import { SagaTrackerClient as GrpcWebClient } from 'grpc-web'; // Fictional client for browser

// --- Custom Hook for gRPC Streaming ---
const useSagaSubscription = (sagaId: string, initialSagaState: SagaStateResponse.AsObject) => {
  const [sagaState, setSagaState] = useState<SagaStateResponse.AsObject>(initialSagaState);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // This effect runs only on the client after hydration.
    // In a real app, you'd initialize your grpc-web client here.
    console.log('Client-side: Subscribing to gRPC stream for', sagaId);

    // This is where you would connect to the gRPC-Web stream.
    // const client = new GrpcWebClient('http://localhost:8080');
    // const stream = client.subscribeToSagaUpdates({ sagaId });
    // For demonstration, we'll leave this part conceptual as setting up
    // gRPC-web with an Envoy proxy is beyond this scope. The testing
    // part is the core focus.

    // stream.on('data', (update: SagaUpdate) => {
    //   // Reducer logic to apply the update to the state
    //   setSagaState(prevState => applySagaUpdate(prevState, update));
    // });
    // stream.on('error', (err) => {
    //   setError(`Stream error: ${err.message}`);
    // });
    // stream.on('end', () => {
    //   console.log('Stream ended');
    // });

    // return () => stream.cancel();

  }, [sagaId]);

  return { sagaState, error };
};


// --- The React Component ---
interface SagaMonitorPageProps {
  initialSagaState: SagaStateResponse.AsObject;
}

const SagaMonitorPage: NextPage<SagaMonitorPageProps> = ({ initialSagaState }) => {
  const { sagaState, error } = useSagaSubscription(initialSagaState.saga_id, initialSagaState);

  const renderStatusBadge = (status: number) => {
    // Mapping from enum number to string representation
    const statusMap: { [key: number]: { text: string; color: string } } = {
        1: { text: "Pending", color: "gray" },
        2: { text: "In Progress", color: "blue" },
        3: { text: "Completed", color: "green" },
        4: { text: "Failed", color: "red" },
        5: { text: "Compensating", color: "orange" },
        6: { text: "Compensated", color: "yellow" },
    };
    const { text, color } = statusMap[status] || { text: "Unknown", color: "black" };
    return <span style={{ backgroundColor: color, color: 'white', padding: '2px 8px', borderRadius: '4px' }}>{text}</span>;
  };

  return (
    <div style={{ fontFamily: 'sans-serif', padding: '2rem' }}>
      <h1>Saga Monitor</h1>
      <p><strong>Saga ID:</strong> {sagaState.saga_id}</p>
      <p><strong>Overall Status:</strong> {renderStatusBadge(sagaState.status)}</p>
      
      {sagaState.error_message && <p style={{ color: 'red' }}><strong>Error:</strong> {sagaState.error_message}</p>}
      {error && <p style={{ color: 'red' }}><strong>Stream Error:</strong> {error}</p>}
      
      <h2>Steps</h2>
      <ul style={{ listStyleType: 'none', padding: 0 }}>
        {sagaState.steps.sort((a,b) => a.sequence - b.sequence).map(step => (
          <li key={step.name} style={{ border: '1px solid #ccc', padding: '1rem', marginBottom: '0.5rem' }}>
            <strong>{step.name}</strong> - {renderStatusBadge(step.status)}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default SagaMonitorPage;

// --- Server-Side Rendering Logic ---
export const getServerSideProps: GetServerSideProps = async (context) => {
  const { sagaId } = context.params as { sagaId: string };

  const client = getSagaTrackerServerClient();
  
  const getSagaState = (): Promise<SagaStateResponse> => {
    return new Promise((resolve, reject) => {
      client.getSagaState({ saga_id: sagaId }, (err, response) => {
        if (err) {
          return reject(err);
        }
        resolve(response);
      });
    });
  };

  try {
    const response = await getSagaState();
    return {
      props: {
        // A crucial step: `.toObject()` converts the gRPC message class to a plain JS object
        // that can be serialized and sent to the client as props.
        initialSagaState: response.toObject(),
      },
    };
  } catch (error) {
    console.error('SSR Error fetching saga state:', error);
    // Handle error, e.g., return a 404 or an error prop
    return { notFound: true };
  }
};

This code successfully separates the concerns. getServerSideProps handles the initial data load. The useSagaSubscription hook handles the client-side real-time updates. The component itself is purely presentational, reacting to the state provided by the hook. This separation is what will make testing manageable.

The Testing Nightmare and The Solution

The core problem is this: how do we test the SagaMonitorPage component? A standard render(<SagaMonitorPage ... />) call in Jest will work for the initial SSR props, but it won’t test any of the asynchronous updates from the gRPC stream. The stream connection is a side effect inside useEffect.

The solution is to mock the entire gRPC client layer. We need a mock that allows us to control the stream lifecycle from within our test cases.

Here is a mock gRPC stream implementation for Jest. This is the most critical piece of the testing setup.

// __tests__/mocks/grpcStreamMock.ts
import { EventEmitter } from 'events';

// This mock emulates the behavior of a gRPC server-side stream client.
// It allows us to programmatically send 'data', 'error', and 'end' events.
export class MockGrpcStream extends EventEmitter {
  public cancel = jest.fn();

  // Test utility to simulate a server message
  public send(data: any): void {
    this.emit('data', data);
  }

  // Test utility to simulate a stream error
  public sendError(error: Error): void {
    this.emit('error', error);
  }
  
  // Test utility to simulate the stream closing
  public endStream(): void {
    this.emit('end');
  }
}

// This mock will replace the actual gRPC-Web client in our tests.
export const mockSagaTrackerClient = {
  subscribeToSagaUpdates: jest.fn(),
};

// We will configure this mock in our test setup file or directly in the test.
jest.mock('../../lib/sagaClient', () => ({
    // We don't need to mock the server client as its output is just props
    getSagaTrackerServerClient: jest.fn(),
    // This is the important part: mocking the client-side implementation
    getSagaTrackerWebClient: () => mockSagaTrackerClient,
}));

Now, with this mock in place, we can write comprehensive tests for the component’s behavior over time. We will provide the initial SSR props and then use our MockGrpcStream instance to push updates and observe how the component reacts using React Testing Library.

// __tests__/pages/SagaMonitorPage.test.tsx
import { render, screen, act, waitFor } from '@testing-library/react';
import SagaMonitorPage from '../../pages/saga/[sagaId]';
import { mockSagaTrackerClient, MockGrpcStream } from '../mocks/grpcStreamMock';
import '@testing-library/jest-dom';

const SAGA_STATUS = { PENDING: 1, IN_PROGRESS: 2, COMPLETED: 3, FAILED: 4, COMPENSATING: 5, COMPENSATED: 6 };

// Helper to create a deep copy of initial state for each test
const getInitialProps = () => ({
  initialSagaState: {
    saga_id: 'test-saga-123',
    status: SAGA_STATUS.IN_PROGRESS,
    steps: [
      { name: 'Provisioning Database', status: SAGA_STATUS.COMPLETED, sequence: 1 },
      { name: 'Provisioning Cache', status: SAGA_STATUS.IN_PROGRESS, sequence: 2 },
      { name: 'Provisioning Instance', status: SAGA_STATUS.PENDING, sequence: 3 },
    ],
    error_message: '',
  },
});

describe('SagaMonitorPage', () => {
  let mockStream: MockGrpcStream;

  beforeEach(() => {
    // Before each test, create a new mock stream
    mockStream = new MockGrpcStream();
    // Configure the mock client to return our controllable stream
    mockSagaTrackerClient.subscribeToSagaUpdates.mockReturnValue(mockStream);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it('should render the initial state from SSR props correctly', () => {
    render(<SagaMonitorPage {...getInitialProps()} />);
    
    expect(screen.getByText('Saga ID:')).toHaveTextContent('test-saga-123');
    // Check initial overall status
    expect(screen.getByText('Overall Status:').querySelector('span')).toHaveTextContent('In Progress');

    // Check initial step statuses
    const databaseStep = screen.getByText('Provisioning Database').parentElement;
    const cacheStep = screen.getByText('Provisioning Cache').parentElement;
    const instanceStep = screen.getByText('Provisioning Instance').parentElement;

    expect(databaseStep).toHaveTextContent('Completed');
    expect(cacheStep).toHaveTextContent('In Progress');
    expect(instanceStep).toHaveTextContent('Pending');
  });

  it('should update a step status when a "step_update" message is received', async () => {
    render(<SagaMonitorPage {...getInitialProps()} />);

    // The initial status of the cache is "In Progress"
    const cacheStep = screen.getByText('Provisioning Cache').parentElement;
    expect(cacheStep).toHaveTextContent('In Progress');

    // Simulate the server sending an update for the cache step
    act(() => {
        mockStream.send({
            toObject: () => ({ // Mocking the .toObject() method
                saga_id: 'test-saga-123',
                step_update: {
                    step_name: 'Provisioning Cache',
                    step_status: SAGA_STATUS.COMPLETED,
                }
            })
        });
    });

    // Wait for the UI to update
    await waitFor(() => {
      expect(cacheStep).toHaveTextContent('Completed');
    });
  });

  it('should reflect a failed Saga and display the error message', async () => {
    render(<SagaMonitorPage {...getInitialProps()} />);

    // 1. Simulate the cache step failing
    act(() => {
        mockStream.send({
            toObject: () => ({
                saga_id: 'test-saga-123',
                step_update: {
                    step_name: 'Provisioning Cache',
                    step_status: SAGA_STATUS.FAILED,
                    error_message: 'Cache provision failed'
                }
            })
        });
    });

    // 2. Simulate the overall Saga status changing to FAILED
    act(() => {
        mockStream.send({
            toObject: () => ({
                saga_id: 'test-saga-123',
                status_update: {
                    new_status: SAGA_STATUS.FAILED,
                    error_message: 'Saga failed due to cache error'
                }
            })
        });
    });

    await waitFor(() => {
        const cacheStep = screen.getByText('Provisioning Cache').parentElement;
        expect(cacheStep).toHaveTextContent('Failed');
        expect(screen.getByText('Overall Status:').querySelector('span')).toHaveTextContent('Failed');
        expect(screen.getByText('Error:')).toHaveTextContent('Saga failed due to cache error');
    });
  });
  
  it('should follow the entire compensation flow', async () => {
    render(<SagaMonitorPage {...getInitialProps()} />);

    // 1. Fail the cache step (as above)
    act(() => {
        mockStream.send({ toObject: () => ({ saga_id: 'test-saga-123', step_update: { step_name: 'Provisioning Cache', step_status: SAGA_STATUS.FAILED }}) });
    });

    // 2. Saga enters COMPENSATING state
    act(() => {
        mockStream.send({ toObject: () => ({ saga_id: 'test-saga-123', status_update: { new_status: SAGA_STATUS.COMPENSATING }}) });
    });

    await screen.findByText('Compensating');
    expect(screen.getByText('Overall Status:').querySelector('span')).toHaveTextContent('Compensating');

    // 3. The completed "Database" step is now compensated
    act(() => {
        mockStream.send({ toObject: () => ({ saga_id: 'test-saga-123', step_update: { step_name: 'Provisioning Database', step_status: SAGA_STATUS.COMPENSATED }}) });
    });
    
    const databaseStep = await screen.findByText('Provisioning Database');
    await waitFor(() => expect(databaseStep.parentElement).toHaveTextContent('Compensated'));

    // 4. Finally, the whole Saga is marked as compensated
    act(() => {
        mockStream.send({ toObject: () => ({ saga_id: 'test-saga-123', status_update: { new_status: SAGA_STATUS.COMPENSATED }}) });
    });

    await waitFor(() => {
        expect(screen.getByText('Overall Status:').querySelector('span')).toHaveTextContent('Compensated');
    });
  });

});

The diagram below illustrates the entire data flow from the user’s request to the final, updated UI state.

sequenceDiagram
    participant User
    participant NextJSServer as Next.js Server (SSR)
    participant gRPCServer as gRPC Saga Orchestrator
    participant Browser
    participant ReactComponent as React Component

    User->>Browser: Navigates to /saga/xyz
    Browser->>NextJSServer: GET /saga/xyz
    NextJSServer->>gRPCServer: GetSagaState(saga_id: 'xyz') [Unary]
    gRPCServer-->>NextJSServer: SagaStateResponse (initial state)
    NextJSServer->>Browser: Serves HTML with initial state
    Browser->>ReactComponent: Renders initial HTML
    
    Note over ReactComponent: Hydration complete. useEffect runs.

    ReactComponent->>gRPCServer: SubscribeToSagaUpdates(saga_id: 'xyz') [Stream]
    gRPCServer-->>ReactComponent: Stream connection established
    
    loop Real-time Updates
        gRPCServer-->>ReactComponent: Pushes SagaUpdate message
        ReactComponent->>ReactComponent: Updates state via setState
        ReactComponent->>Browser: Re-renders component with new state
    end

This testing strategy provides high confidence in the component’s behavior. We can simulate any sequence of Saga events—success, immediate failure, failure after several steps, network errors on the stream—and assert that the UI presents the correct information to the user at all times. This is impossible with simple snapshot tests or tests that only check the initial render.

The solution isn’t without its own complexities. The mock gRPC stream and client must be maintained, and as the protocol evolves, the mocks must be updated. This is a maintenance cost, but one that is well worth the price for the stability it provides. Furthermore, setting up gRPC-web for the browser environment often requires a proxy like Envoy, which adds another layer to the production infrastructure. These are trade-offs. In our case, the benefits of a strongly-typed, real-time, and efficiently rendered UI for a critical business process far outweighed the setup and maintenance overhead. The architecture also left room for future enhancements, such as adding client-side actions (e.g., a “cancel saga” button) that would send a message back to the server over the same gRPC connection, potentially using a bidirectional stream.


  TOC