Skip to content

Commit da386d7

Browse files
committed
Add failing end-to-end test for an action that returns client components
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`.
1 parent 2b00018 commit da386d7

File tree

7 files changed

+102
-11
lines changed

7 files changed

+102
-11
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// @ts-check
2+
3+
import {test, expect} from '@playwright/test';
4+
5+
test('action returning client component with deduped references', async ({
6+
page,
7+
}) => {
8+
const pageErrors = [];
9+
10+
page.on('pageerror', error => {
11+
pageErrors.push(error.stack);
12+
});
13+
14+
await page.goto('/');
15+
16+
const button = await page.getByRole('button', {
17+
name: 'Return element from action',
18+
});
19+
20+
await button.click();
21+
22+
await expect(
23+
page.getByTestId('temporary-references-action-result')
24+
).toHaveText('Hello');
25+
26+
// Click the button one more time to send the previous result (i.e. the
27+
// returned element) back to the server.
28+
await button.click();
29+
30+
await expect(pageErrors).toEqual([]);
31+
32+
await expect(
33+
page.getByTestId('temporary-references-action-result')
34+
).toHaveText('HelloHello');
35+
});

fixtures/flight/server/region.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const {readFile} = require('fs').promises;
5050

5151
const React = require('react');
5252

53-
async function renderApp(res, returnValue, formState) {
53+
async function renderApp(res, returnValue, formState, temporaryReferences) {
5454
const {renderToPipeableStream} = await import(
5555
'react-server-dom-webpack/server'
5656
);
@@ -101,7 +101,9 @@ async function renderApp(res, returnValue, formState) {
101101
);
102102
// For client-invoked server actions we refresh the tree and return a return value.
103103
const payload = {root, returnValue, formState};
104-
const {pipe} = renderToPipeableStream(payload, moduleMap);
104+
const {pipe} = renderToPipeableStream(payload, moduleMap, {
105+
temporaryReferences,
106+
});
105107
pipe(res);
106108
}
107109

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

112114
app.post('/', bodyParser.text(), async function (req, res) {
113-
const {decodeReply, decodeReplyFromBusboy, decodeAction, decodeFormState} =
114-
await import('react-server-dom-webpack/server');
115+
const {
116+
decodeReply,
117+
decodeReplyFromBusboy,
118+
decodeAction,
119+
decodeFormState,
120+
createTemporaryReferenceSet,
121+
} = await import('react-server-dom-webpack/server');
115122
const serverReference = req.get('rsc-action');
116123
if (serverReference) {
117124
// This is the client-side case
@@ -124,15 +131,17 @@ app.post('/', bodyParser.text(), async function (req, res) {
124131
throw new Error('Invalid action');
125132
}
126133

134+
const temporaryReferences = createTemporaryReferenceSet();
135+
127136
let args;
128137
if (req.is('multipart/form-data')) {
129138
// Use busboy to streamingly parse the reply from form-data.
130139
const bb = busboy({headers: req.headers});
131-
const reply = decodeReplyFromBusboy(bb);
140+
const reply = decodeReplyFromBusboy(bb, {}, {temporaryReferences});
132141
req.pipe(bb);
133142
args = await reply;
134143
} else {
135-
args = await decodeReply(req.body);
144+
args = await decodeReply(req.body, {}, {temporaryReferences});
136145
}
137146
const result = action.apply(null, args);
138147
try {
@@ -142,7 +151,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
142151
// We handle the error on the client
143152
}
144153
// Refresh the client and return the value
145-
renderApp(res, result, null);
154+
renderApp(res, result, null, temporaryReferences);
146155
} else {
147156
// This is the progressive enhancement case
148157
const UndiciRequest = require('undici').Request;

fixtures/flight/src/App.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import Button from './Button.js';
1212
import Form from './Form.js';
1313
import {Dynamic} from './Dynamic.js';
1414
import {Client} from './Client.js';
15+
import {TemporaryReferences} from './TemporaryReferences.js';
1516

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

18-
import {like, greet, increment} from './actions.js';
19+
import {like, greet, increment, returnElement} from './actions.js';
1920

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

@@ -61,6 +62,7 @@ export default async function App() {
6162
</div>
6263
<Client />
6364
<Note />
65+
<TemporaryReferences action={returnElement} />
6466
</Container>
6567
</body>
6668
</html>

fixtures/flight/src/Deduped.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
5+
export default function Deduped({children, thing}) {
6+
console.log({thing});
7+
return <div>{children}</div>;
8+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
5+
export function TemporaryReferences({action}) {
6+
const [result, formAction] = React.useActionState(action, null);
7+
8+
return (
9+
<form action={formAction}>
10+
<button>Return element from action</button>
11+
<div data-testid="temporary-references-action-result">{result}</div>
12+
</form>
13+
);
14+
}

fixtures/flight/src/actions.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use server';
22

3+
import * as React from 'react';
34
import {setServerState} from './ServerState.js';
5+
import Deduped from './Deduped.js';
46

57
export async function like() {
68
setServerState('Liked!');
@@ -22,3 +24,14 @@ export async function greet(formData) {
2224
export async function increment(n) {
2325
return n + 1;
2426
}
27+
28+
export async function returnElement(prevElement) {
29+
const text = <div>Hello</div>;
30+
31+
return (
32+
<Deduped thing={text}>
33+
{prevElement}
34+
{text}
35+
</Deduped>
36+
);
37+
}

fixtures/flight/src/index.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
11
import * as React from 'react';
22
import {use, Suspense, useState, startTransition} from 'react';
33
import ReactDOM from 'react-dom/client';
4-
import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client';
4+
import {
5+
createFromFetch,
6+
createTemporaryReferenceSet,
7+
encodeReply,
8+
} from 'react-server-dom-webpack/client';
59

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

913
let updateRoot;
14+
const temporaryReferences = createTemporaryReferenceSet();
15+
1016
async function callServer(id, args) {
1117
const response = fetch('/', {
1218
method: 'POST',
1319
headers: {
1420
Accept: 'text/x-component',
1521
'rsc-action': id,
1622
},
17-
body: await encodeReply(args),
23+
body: await encodeReply(args, {temporaryReferences}),
24+
});
25+
const {returnValue, root} = await createFromFetch(response, {
26+
callServer,
27+
temporaryReferences,
1828
});
19-
const {returnValue, root} = await createFromFetch(response, {callServer});
2029
// Refresh the tree with the new RSC payload.
2130
startTransition(() => {
2231
updateRoot(root);
@@ -39,6 +48,7 @@ async function hydrateApp() {
3948
}),
4049
{
4150
callServer,
51+
temporaryReferences,
4252
findSourceMapURL(fileName) {
4353
return (
4454
document.location.origin +

0 commit comments

Comments
 (0)