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

[Flight] Serialize pending server components by reference (lazy component) #20137

Merged
merged 1 commit into from
Oct 31, 2020
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: 2 additions & 1 deletion packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ export function resolveModelToJSON(
const newSegment = createSegment(request, () => value);
const ping = newSegment.ping;
x.then(ping, ping);
return serializeByValueID(newSegment.id);
return serializeByRefID(newSegment.id);
} else {
// Something errored. Don't bother encoding anything up to here.
throw x;
Expand Down Expand Up @@ -708,6 +708,7 @@ function flushCompletedChunks(request: Request): void {
break;
}
}
moduleChunks.splice(0, i);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is a bug fix that this test revealed. This causes the connection to close to early because we keep emitting module chunks over and over since they don't get deleted.

// Next comes model data.
const jsonChunks = request.completedJSONChunks;
i = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,28 +64,34 @@ describe('ReactFlightDOM', () => {
};
}

function block(render, load) {
function moduleReference(moduleExport) {
const idx = webpackModuleIdx++;
webpackModules[idx] = {
d: render,
d: moduleExport,
};
webpackMap['path/' + idx] = {
id: '' + idx,
chunks: [],
name: 'd',
};
const MODULE_TAG = Symbol.for('react.module.reference');
return {$$typeof: MODULE_TAG, name: 'path/' + idx};
}

function block(render, load) {
if (load === undefined) {
return () => {
return ReactTransportDOMServerRuntime.serverBlockNoData('path/' + idx);
return ReactTransportDOMServerRuntime.serverBlockNoData(
moduleReference(render),
);
};
}
return function(...args) {
const curriedLoad = () => {
return load(...args);
};
const MODULE_TAG = Symbol.for('react.module.reference');
return ReactTransportDOMServerRuntime.serverBlock(
{$$typeof: MODULE_TAG, name: 'path/' + idx},
moduleReference(render),
curriedLoad,
);
};
Expand Down Expand Up @@ -314,6 +320,9 @@ describe('ReactFlightDOM', () => {
return 'data';
}
function DelayedText({children}, data) {
if (data !== 'data') {
throw new Error('No data');
}
return <Text>{children}</Text>;
}
const loadBlock = block(DelayedText, load);
Expand Down Expand Up @@ -477,4 +486,196 @@ describe('ReactFlightDOM', () => {
'<p>Game over</p>', // TODO: should not have message in prod.
);
});

// @gate experimental
it('should progressively reveal server components', async () => {
const {Suspense} = React;

// Client Components

class ErrorBoundary extends React.Component {
state = {hasError: false, error: null};
static getDerivedStateFromError(error) {
return {
hasError: true,
error,
};
}
render() {
if (this.state.hasError) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}

function MyErrorBoundary({children}) {
return (
<ErrorBoundary fallback={e => <p>{e.message}</p>}>
{children}
</ErrorBoundary>
);
}

function Placeholder({children, fallback}) {
return <Suspense fallback={fallback}>{children}</Suspense>;
}

// Model
function Text({children}) {
return children;
}

function makeDelayedText() {
let error, _resolve, _reject;
let promise = new Promise((resolve, reject) => {
_resolve = () => {
promise = null;
resolve();
};
_reject = e => {
error = e;
promise = null;
reject(e);
};
});
function DelayedText({children}, data) {
if (promise) {
throw promise;
}
if (error) {
throw error;
}
return <Text>{children}</Text>;
}
return [DelayedText, _resolve, _reject];
}

const [Friends, resolveFriends] = makeDelayedText();
const [Name, resolveName] = makeDelayedText();
const [Posts, resolvePosts] = makeDelayedText();
const [Photos, resolvePhotos] = makeDelayedText();
const [Games, , rejectGames] = makeDelayedText();

// View
function ProfileDetails({avatar}) {
return (
<div>
<Name>:name:</Name>
{avatar}
</div>
);
}
function ProfileSidebar({friends}) {
return (
<div>
<Photos>:photos:</Photos>
{friends}
</div>
);
}
function ProfilePosts({posts}) {
return <div>{posts}</div>;
}
function ProfileGames({games}) {
return <div>{games}</div>;
}

const MyErrorBoundaryClient = moduleReference(MyErrorBoundary);
const PlaceholderClient = moduleReference(Placeholder);

function ProfileContent() {
return (
<>
<ProfileDetails avatar={<Text>:avatar:</Text>} />
<PlaceholderClient fallback={<p>(loading sidebar)</p>}>
<ProfileSidebar friends={<Friends>:friends:</Friends>} />
</PlaceholderClient>
<PlaceholderClient fallback={<p>(loading posts)</p>}>
<ProfilePosts posts={<Posts>:posts:</Posts>} />
</PlaceholderClient>
<MyErrorBoundaryClient>
<PlaceholderClient fallback={<p>(loading games)</p>}>
<ProfileGames games={<Games>:games:</Games>} />
</PlaceholderClient>
</MyErrorBoundaryClient>
</>
);
}

const model = {
rootContent: <ProfileContent />,
};

function ProfilePage({response}) {
return response.readRoot().rootContent;
}

const {writable, readable} = getTestStream();
ReactTransportDOMServer.pipeToNodeWritable(model, writable, webpackMap);
const response = ReactTransportDOMClient.createFromReadableStream(readable);

const container = document.createElement('div');
const root = ReactDOM.unstable_createRoot(container);
await act(async () => {
root.render(
<Suspense fallback={<p>(loading)</p>}>
<ProfilePage response={response} />
</Suspense>,
);
});
expect(container.innerHTML).toBe('<p>(loading)</p>');

// This isn't enough to show anything.
await act(async () => {
resolveFriends();
});
expect(container.innerHTML).toBe('<p>(loading)</p>');

// We can now show the details. Sidebar and posts are still loading.
await act(async () => {
resolveName();
});
// Advance time enough to trigger a nested fallback.
jest.advanceTimersByTime(500);
expect(container.innerHTML).toBe(
'<div>:name::avatar:</div>' +
'<p>(loading sidebar)</p>' +
'<p>(loading posts)</p>' +
'<p>(loading games)</p>',
);

// Let's *fail* loading games.
await act(async () => {
rejectGames(new Error('Game over'));
});
expect(container.innerHTML).toBe(
'<div>:name::avatar:</div>' +
'<p>(loading sidebar)</p>' +
'<p>(loading posts)</p>' +
'<p>Game over</p>', // TODO: should not have message in prod.
);

// We can now show the sidebar.
await act(async () => {
resolvePhotos();
});
expect(container.innerHTML).toBe(
'<div>:name::avatar:</div>' +
'<div>:photos::friends:</div>' +
'<p>(loading posts)</p>' +
'<p>Game over</p>', // TODO: should not have message in prod.
);

// Show everything.
await act(async () => {
resolvePosts();
});
expect(container.innerHTML).toBe(
'<div>:name::avatar:</div>' +
'<div>:photos::friends:</div>' +
'<div>:posts:</div>' +
'<p>Game over</p>', // TODO: should not have message in prod.
);
});
});