Skip to content

Commit

Permalink
Add failing end-to-end test for an action that returns client components
Browse files Browse the repository at this point in the history
This playwright test is using the Flight Fixture to demonstrate the
Flight Reply equivalent of the scenario that was fixed in facebook#30528 for the
Flight Client.

It's basically an advanced case of what was outlined in facebook#28564,
returning a client component from a server action that is used in
`useActionState`. In addition, the client component uses another element
twice, which leads to the element's props being deduped. Resolving those
references needs to be handled specifically, both in the Flight Client
(done in facebook#30528), as well as in the temporary references of the Flight
Reply Client (and possibly Flight Reply Server?).

The test should probably be converted into a unit test, e.g. in
`packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js`
or
`packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js`.
  • Loading branch information
unstubbable committed Jul 31, 2024
1 parent 2b00018 commit da386d7
Show file tree
Hide file tree
Showing 7 changed files with 102 additions and 11 deletions.
35 changes: 35 additions & 0 deletions fixtures/flight/__tests__/__e2e__/action.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @ts-check

import {test, expect} from '@playwright/test';

test('action returning client component with deduped references', async ({
page,
}) => {
const pageErrors = [];

page.on('pageerror', error => {
pageErrors.push(error.stack);
});

await page.goto('/');

const button = await page.getByRole('button', {
name: 'Return element from action',
});

await button.click();

await expect(
page.getByTestId('temporary-references-action-result')
).toHaveText('Hello');

// Click the button one more time to send the previous result (i.e. the
// returned element) back to the server.
await button.click();

await expect(pageErrors).toEqual([]);

await expect(
page.getByTestId('temporary-references-action-result')
).toHaveText('HelloHello');
});
23 changes: 16 additions & 7 deletions fixtures/flight/server/region.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const {readFile} = require('fs').promises;

const React = require('react');

async function renderApp(res, returnValue, formState) {
async function renderApp(res, returnValue, formState, temporaryReferences) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
Expand Down Expand Up @@ -101,7 +101,9 @@ async function renderApp(res, returnValue, formState) {
);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = {root, returnValue, formState};
const {pipe} = renderToPipeableStream(payload, moduleMap);
const {pipe} = renderToPipeableStream(payload, moduleMap, {
temporaryReferences,
});
pipe(res);
}

Expand All @@ -110,8 +112,13 @@ app.get('/', async function (req, res) {
});

app.post('/', bodyParser.text(), async function (req, res) {
const {decodeReply, decodeReplyFromBusboy, decodeAction, decodeFormState} =
await import('react-server-dom-webpack/server');
const {
decodeReply,
decodeReplyFromBusboy,
decodeAction,
decodeFormState,
createTemporaryReferenceSet,
} = await import('react-server-dom-webpack/server');
const serverReference = req.get('rsc-action');
if (serverReference) {
// This is the client-side case
Expand All @@ -124,15 +131,17 @@ app.post('/', bodyParser.text(), async function (req, res) {
throw new Error('Invalid action');
}

const temporaryReferences = createTemporaryReferenceSet();

let args;
if (req.is('multipart/form-data')) {
// Use busboy to streamingly parse the reply from form-data.
const bb = busboy({headers: req.headers});
const reply = decodeReplyFromBusboy(bb);
const reply = decodeReplyFromBusboy(bb, {}, {temporaryReferences});
req.pipe(bb);
args = await reply;
} else {
args = await decodeReply(req.body);
args = await decodeReply(req.body, {}, {temporaryReferences});
}
const result = action.apply(null, args);
try {
Expand All @@ -142,7 +151,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
// We handle the error on the client
}
// Refresh the client and return the value
renderApp(res, result, null);
renderApp(res, result, null, temporaryReferences);
} else {
// This is the progressive enhancement case
const UndiciRequest = require('undici').Request;
Expand Down
4 changes: 3 additions & 1 deletion fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import Button from './Button.js';
import Form from './Form.js';
import {Dynamic} from './Dynamic.js';
import {Client} from './Client.js';
import {TemporaryReferences} from './TemporaryReferences.js';

import {Note} from './cjs/Note.js';

import {like, greet, increment} from './actions.js';
import {like, greet, increment, returnElement} from './actions.js';

import {getServerState} from './ServerState.js';

Expand Down Expand Up @@ -61,6 +62,7 @@ export default async function App() {
</div>
<Client />
<Note />
<TemporaryReferences action={returnElement} />
</Container>
</body>
</html>
Expand Down
8 changes: 8 additions & 0 deletions fixtures/flight/src/Deduped.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client';

import * as React from 'react';

export default function Deduped({children, thing}) {
console.log({thing});
return <div>{children}</div>;
}
14 changes: 14 additions & 0 deletions fixtures/flight/src/TemporaryReferences.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

import * as React from 'react';

export function TemporaryReferences({action}) {
const [result, formAction] = React.useActionState(action, null);

return (
<form action={formAction}>
<button>Return element from action</button>
<div data-testid="temporary-references-action-result">{result}</div>
</form>
);
}
13 changes: 13 additions & 0 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use server';

import * as React from 'react';
import {setServerState} from './ServerState.js';
import Deduped from './Deduped.js';

export async function like() {
setServerState('Liked!');
Expand All @@ -22,3 +24,14 @@ export async function greet(formData) {
export async function increment(n) {
return n + 1;
}

export async function returnElement(prevElement) {
const text = <div>Hello</div>;

return (
<Deduped thing={text}>
{prevElement}
{text}
</Deduped>
);
}
16 changes: 13 additions & 3 deletions fixtures/flight/src/index.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import * as React from 'react';
import {use, Suspense, useState, startTransition} from 'react';
import ReactDOM from 'react-dom/client';
import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client';
import {
createFromFetch,
createTemporaryReferenceSet,
encodeReply,
} from 'react-server-dom-webpack/client';

// TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet.
import './style.css';

let updateRoot;
const temporaryReferences = createTemporaryReferenceSet();

async function callServer(id, args) {
const response = fetch('/', {
method: 'POST',
headers: {
Accept: 'text/x-component',
'rsc-action': id,
},
body: await encodeReply(args),
body: await encodeReply(args, {temporaryReferences}),
});
const {returnValue, root} = await createFromFetch(response, {
callServer,
temporaryReferences,
});
const {returnValue, root} = await createFromFetch(response, {callServer});
// Refresh the tree with the new RSC payload.
startTransition(() => {
updateRoot(root);
Expand All @@ -39,6 +48,7 @@ async function hydrateApp() {
}),
{
callServer,
temporaryReferences,
findSourceMapURL(fileName) {
return (
document.location.origin +
Expand Down

0 comments on commit da386d7

Please sign in to comment.