Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for bind with server actions in server components #25

Merged
merged 2 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/cloudflare-app/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ export default function createConfigs(_env, argv) {
`@mfng:internal:node`,
`@mfng:internal`,
`workerd`,
`node`,
`...`,
],
},
Expand All @@ -118,7 +117,7 @@ export default function createConfigs(_env, argv) {
},
{
issuerLayer: webpackRscLayerName,
resolve: {conditionNames: [`react-server`, `node`, `...`]},
resolve: {conditionNames: [`react-server`, `workerd`, `...`]},
},
{
oneOf: [
Expand Down
11 changes: 9 additions & 2 deletions apps/shared-app/src/client/product.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
import {clsx} from 'clsx';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {buy} from '../server/buy.js';
import type {BuyResult} from '../server/buy.js';
import {Notification} from '../shared/notification.js';
import {Button} from './button.js';

export function Product(): JSX.Element {
export interface ProductProps {
readonly buy: (
prevResult: BuyResult | undefined,
formData: FormData,
) => Promise<BuyResult>;
}

export function Product({buy}: ProductProps): JSX.Element {
const [result, formAction] = ReactDOM.useFormState(buy, undefined);

return (
Expand Down
6 changes: 6 additions & 0 deletions apps/shared-app/src/server/buy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ async function fetchAvailableProductCount(): Promise<number> {
}

export async function buy(
productId: string,
prevResult: BuyResult | undefined,
formData: FormData,
): Promise<BuyResult> {
Expand Down Expand Up @@ -84,6 +85,11 @@ export async function buy(
const {quantity} = result.data;

// Buy quantity number of items ...
console.log(
`Buying ${quantity} ${
quantity === 1 ? `item` : `items`
} of product ${productId}...`,
);

return {
status: `success`,
Expand Down
3 changes: 2 additions & 1 deletion apps/shared-app/src/server/home-page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import {Product} from '../client/product.js';
import {Main} from '../shared/main.js';
import {buy} from './buy.js';
import {Hello} from './hello.js';
import {Suspended} from './suspended.js';

Expand All @@ -12,7 +13,7 @@ export function HomePage(): JSX.Element {
<Suspended />
</React.Suspense>
<React.Suspense>
<Product />
<Product buy={buy.bind(null, `some-product-id`)} />
</React.Suspense>
</Main>
);
Expand Down
3 changes: 1 addition & 2 deletions apps/vercel-app/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ export default function createConfigs(_env, argv) {
`@mfng:internal:node`,
`@mfng:internal`,
`workerd`,
`node`,
`...`,
],
},
Expand All @@ -145,7 +144,7 @@ export default function createConfigs(_env, argv) {
},
{
issuerLayer: webpackRscLayerName,
resolve: {conditionNames: [`react-server`, `node`, `...`]},
resolve: {conditionNames: [`react-server`, `workerd`, `...`]},
},
{
oneOf: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
'use client';

import * as React from 'react';
import {serverFunction} from './server-function.js';
import {serverFunctionImportedFromClient} from './server-function-imported-from-client.js';

export function ClientComponentWithServerAction() {
export function ClientComponentWithServerAction({action}) {
React.useEffect(() => {
serverFunction().then(console.log);
action().then(console.log);
serverFunctionImportedFromClient().then(console.log);
}, []);

return null;
Expand Down
7 changes: 3 additions & 4 deletions packages/webpack-rsc/src/__fixtures__/main.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as React from 'react';
import {serverFunction} from './server-function.js';
import {pretendRscRendering} from './rsc.js';

export function Main() {
return React.createElement(`div`, {action: serverFunction});
export function pretendSsrRendering() {
console.log(pretendRscRendering());
}
13 changes: 13 additions & 0 deletions packages/webpack-rsc/src/__fixtures__/rsc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import {ClientComponentWithServerAction} from './client-component-with-server-action.js';
import {serverFunctionPassedFromServer} from './server-function-passed-from-server.js';

function Main() {
return React.createElement(ClientComponentWithServerAction, {
action: serverFunctionPassedFromServer,
});
}

export function pretendRscRendering() {
console.log(React.createElement(Main));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use server';

export async function serverFunctionImportedFromClient() {
return Promise.resolve(`server-function-imported-from-client`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use server';

export async function serverFunctionPassedFromServer() {
return Promise.resolve(`server-function-passed-from-server`);
}
5 changes: 0 additions & 5 deletions packages/webpack-rsc/src/__fixtures__/server-function.js

This file was deleted.

6 changes: 3 additions & 3 deletions packages/webpack-rsc/src/webpack-rsc-client-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe(`webpackRscClientLoader`, () => {
test(`generates a server reference module based on given serverReferencesMap`, async () => {
const resourcePath = path.resolve(
currentDirname,
`__fixtures__/server-function.js`,
`__fixtures__/server-functions.js`,
);

const serverReferencesMap: ServerReferencesMap = new Map([
Expand All @@ -74,7 +74,7 @@ export const bar = createServerReference("test#bar", callServer);
test(`accepts a custom callServer import source`, async () => {
const resourcePath = path.resolve(
currentDirname,
`__fixtures__/server-function.js`,
`__fixtures__/server-functions.js`,
);

const serverReferencesMap: ServerReferencesMap = new Map([
Expand All @@ -100,7 +100,7 @@ export const foo = createServerReference("test#foo", callServer);
test(`emits an error if module info is missing in serverReferencesMap`, async () => {
const resourcePath = path.resolve(
currentDirname,
`__fixtures__/server-function.js`,
`__fixtures__/server-functions.js`,
);

const serverReferencesMap: ServerReferencesMap = new Map();
Expand Down
84 changes: 63 additions & 21 deletions packages/webpack-rsc/src/webpack-rsc-server-loader.cts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import generate = require('@babel/generator');
import parser = require('@babel/parser');
import traverse = require('@babel/traverse');
import t = require('@babel/types');
import type {LoaderContext} from 'webpack';
import webpack = require('webpack');

namespace webpackRscServerLoader {
export interface WebpackRscServerLoaderOptions {
Expand All @@ -22,7 +22,7 @@ namespace webpackRscServerLoader {
}

function webpackRscServerLoader(
this: LoaderContext<webpackRscServerLoader.WebpackRscServerLoaderOptions>,
this: webpack.LoaderContext<webpackRscServerLoader.WebpackRscServerLoaderOptions>,
source: string,
): void {
this.cacheable(true);
Expand All @@ -35,47 +35,69 @@ function webpackRscServerLoader(
sourceFilename: resourcePath,
});

let hasUseClientDirective = false;
let moduleDirective: 'use client' | 'use server' | undefined;
let addedRegisterServerReferenceCall = false;
const clientReferences: webpackRscServerLoader.ClientReference[] = [];

traverse.default(ast, {
enter(nodePath) {
const {node} = nodePath;

if (t.isProgram(node)) {
if (node.directives.some(isUseClientDirective)) {
hasUseClientDirective = true;
if (node.directives.some(isDirective(`use client`))) {
moduleDirective = `use client`;
} else if (node.directives.some(isDirective(`use server`))) {
moduleDirective = `use server`;
} else {
nodePath.skip();
}

return;
}

if (t.isDirective(node) && isUseClientDirective(node)) {
if (
!moduleDirective ||
(t.isDirective(node) &&
(isDirective(`use client`)(node) || isDirective(`use server`)(node)))
) {
nodePath.skip();

return;
}

const exportName = getExportName(node);

if (exportName) {
const id = `${path.relative(
process.cwd(),
resourcePath,
)}#${exportName}`;
if (moduleDirective === `use client`) {
if (exportName) {
const id = `${path.relative(
process.cwd(),
resourcePath,
)}#${exportName}`;

clientReferences.push({id, exportName});
nodePath.replaceWith(createExportedClientReference(id, exportName));
clientReferences.push({id, exportName});
nodePath.replaceWith(createExportedClientReference(id, exportName));
nodePath.skip();
} else {
nodePath.remove();
}
} else if (exportName) {
addedRegisterServerReferenceCall = true;
nodePath.insertAfter(createRegisterServerReference(exportName));
nodePath.skip();
} else {
nodePath.remove();
}
},
exit(nodePath) {
const {node} = nodePath;

if (t.isProgram(node) && addedRegisterServerReferenceCall) {
(nodePath as traverse.NodePath<t.Program>).unshiftContainer(`body`, [
creatRegisterServerReferenceImport(),
]);
}
},
});

if (!hasUseClientDirective) {
if (!moduleDirective) {
return this.callback(null, source);
}

Expand All @@ -90,11 +112,11 @@ function webpackRscServerLoader(
this.callback(null, code);
}

function isUseClientDirective(directive: t.Directive): boolean {
return (
t.isDirectiveLiteral(directive.value) &&
directive.value.value === `use client`
);
function isDirective(
value: 'use client' | 'use server',
): (directive: t.Directive) => boolean {
return (directive) =>
t.isDirectiveLiteral(directive.value) && directive.value.value === value;
}

function getExportName(node: t.Node): string | undefined {
Expand Down Expand Up @@ -136,4 +158,24 @@ function createExportedClientReference(
);
}

function createRegisterServerReference(exportName: string): t.CallExpression {
return t.callExpression(t.identifier(`registerServerReference`), [
t.identifier(exportName),
t.identifier(webpack.RuntimeGlobals.moduleId),
t.stringLiteral(exportName),
]);
}

function creatRegisterServerReferenceImport(): t.ImportDeclaration {
return t.importDeclaration(
[
t.importSpecifier(
t.identifier(`registerServerReference`),
t.identifier(`registerServerReference`),
),
],
t.stringLiteral(`react-server-dom-webpack/server`),
);
}

export = webpackRscServerLoader;
29 changes: 28 additions & 1 deletion packages/webpack-rsc/src/webpack-rsc-server-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,34 @@ export const ComponentC = {
);
});

test(`does not change modules without a 'use client' directive`, async () => {
test(`adds 'registerServerReference' calls to all exported functions of a module with a 'use server' directive`, async () => {
const clientReferencesMap: ClientReferencesMap = new Map();

const resourcePath = path.resolve(
currentDirname,
`__fixtures__/server-functions.js`,
);

const output = await callLoader(resourcePath, clientReferencesMap);

expect(output).toEqual(
`
'use server';

import { registerServerReference } from "react-server-dom-webpack/server";
export async function foo() {
return Promise.resolve(\`foo\`);
}
registerServerReference(foo, module.id, "foo")
export const bar = async () => Promise.resolve(\`bar\`);
registerServerReference(bar, module.id, "bar")
export const baz = 42;
registerServerReference(baz, module.id, "baz")
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, that's an unexpected expectation.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed with #39

`.trim(),
);
});

test(`does not change modules without a 'use client' or 'use server' directive`, async () => {
const clientReferencesMap: ClientReferencesMap = new Map();

const resourcePath = path.resolve(
Expand Down
Loading