mirror of
https://github.com/withastro/astro.git
synced 2025-01-27 22:19:04 -05:00
fix(rendering): allow framework renders to be cancelled (#10448)
This commit is contained in:
parent
36ebe758e1
commit
fcece36586
11 changed files with 97 additions and 29 deletions
5
.changeset/slow-rabbits-care.md
Normal file
5
.changeset/slow-rabbits-care.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"astro": patch
|
||||
---
|
||||
|
||||
Fixes an issue where multiple rendering errors resulted in a crash of the SSR app server.
|
|
@ -2763,6 +2763,10 @@ export type SSRComponentMetadata = {
|
|||
};
|
||||
|
||||
export interface SSRResult {
|
||||
/**
|
||||
* Whether the page has failed with a non-recoverable error, or the client disconnected.
|
||||
*/
|
||||
cancelled: boolean;
|
||||
styles: Set<SSRElement>;
|
||||
scripts: Set<SSRElement>;
|
||||
links: Set<SSRElement>;
|
||||
|
|
|
@ -87,17 +87,16 @@ export class RenderContext {
|
|||
serverLike,
|
||||
});
|
||||
const apiContext = this.createAPIContext(props);
|
||||
const { type } = routeData;
|
||||
|
||||
const lastNext =
|
||||
type === 'endpoint'
|
||||
? () => renderEndpoint(componentInstance as any, apiContext, serverLike, logger)
|
||||
: type === 'redirect'
|
||||
? () => renderRedirect(this)
|
||||
: type === 'page'
|
||||
? async () => {
|
||||
const lastNext = async () => {
|
||||
switch (routeData.type) {
|
||||
case 'endpoint': return renderEndpoint(componentInstance as any, apiContext, serverLike, logger);
|
||||
case 'redirect': return renderRedirect(this);
|
||||
case 'page': {
|
||||
const result = await this.createResult(componentInstance!);
|
||||
const response = await renderPage(
|
||||
let response: Response;
|
||||
try {
|
||||
response = await renderPage(
|
||||
result,
|
||||
componentInstance?.default as any,
|
||||
props,
|
||||
|
@ -105,6 +104,12 @@ export class RenderContext {
|
|||
streaming,
|
||||
routeData
|
||||
);
|
||||
} catch (e) {
|
||||
// If there is an error in the page's frontmatter or instantiation of the RenderTemplate fails midway,
|
||||
// we signal to the rest of the internals that we can ignore the results of existing renders and avoid kicking off more of them.
|
||||
result.cancelled = true;
|
||||
throw e;
|
||||
}
|
||||
// Signal to the i18n middleware to maybe act on this response
|
||||
response.headers.set(ROUTE_TYPE_HEADER, 'page');
|
||||
// Signal to the error-page-rerouting infra to let this response pass through to avoid loops
|
||||
|
@ -112,13 +117,14 @@ export class RenderContext {
|
|||
response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
: type === 'fallback'
|
||||
? () =>
|
||||
}
|
||||
case 'fallback': {
|
||||
return (
|
||||
new Response(null, { status: 500, headers: { [ROUTE_TYPE_HEADER]: 'fallback' } })
|
||||
: () => {
|
||||
throw new Error('Unknown type of route: ' + type);
|
||||
};
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await callMiddleware(middleware, apiContext, lastNext);
|
||||
if (response.headers.get(ROUTE_TYPE_HEADER)) {
|
||||
|
@ -200,6 +206,7 @@ export class RenderContext {
|
|||
// This object starts here as an empty shell (not yet the result) but then
|
||||
// calling the render() function will populate the object with scripts, styles, etc.
|
||||
const result: SSRResult = {
|
||||
cancelled: false,
|
||||
clientDirectives,
|
||||
inlinedScripts,
|
||||
componentMetadata,
|
||||
|
|
|
@ -125,6 +125,11 @@ export async function renderToReadableStream(
|
|||
}
|
||||
})();
|
||||
},
|
||||
cancel() {
|
||||
// If the client disconnects,
|
||||
// we signal to ignore the results of existing renders and avoid kicking off more of them.
|
||||
result.cancelled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -201,13 +206,11 @@ export async function renderToAsyncIterable(
|
|||
// The `next` is an object `{ promise, resolve, reject }` that we use to wait
|
||||
// for chunks to be pushed into the buffer.
|
||||
let next = promiseWithResolvers<void>();
|
||||
// keep track of whether the client connection is still interested in the response.
|
||||
let cancelled = false;
|
||||
const buffer: Uint8Array[] = []; // []Uint8Array
|
||||
|
||||
const iterator: AsyncIterator<Uint8Array> = {
|
||||
async next() {
|
||||
if (cancelled) return { done: true, value: undefined };
|
||||
if (result.cancelled) return { done: true, value: undefined };
|
||||
|
||||
await next.promise;
|
||||
|
||||
|
@ -243,7 +246,9 @@ export async function renderToAsyncIterable(
|
|||
return returnValue;
|
||||
},
|
||||
async return() {
|
||||
cancelled = true;
|
||||
// If the client disconnects,
|
||||
// we signal to the rest of the internals to ignore the results of existing renders and avoid kicking off more of them.
|
||||
result.cancelled = true;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
};
|
||||
|
|
|
@ -459,11 +459,13 @@ export async function renderComponent(
|
|||
slots: any = {}
|
||||
): Promise<RenderInstance> {
|
||||
if (isPromise(Component)) {
|
||||
Component = await Component;
|
||||
Component = await Component
|
||||
.catch(handleCancellation);
|
||||
}
|
||||
|
||||
if (isFragmentComponent(Component)) {
|
||||
return await renderFragmentComponent(result, slots);
|
||||
return await renderFragmentComponent(result, slots)
|
||||
.catch(handleCancellation);
|
||||
}
|
||||
|
||||
// Ensure directives (`class:list`) are processed
|
||||
|
@ -471,14 +473,21 @@ export async function renderComponent(
|
|||
|
||||
// .html components
|
||||
if (isHTMLComponent(Component)) {
|
||||
return await renderHTMLComponent(result, Component, props, slots);
|
||||
return await renderHTMLComponent(result, Component, props, slots)
|
||||
.catch(handleCancellation);
|
||||
}
|
||||
|
||||
if (isAstroComponentFactory(Component)) {
|
||||
return renderAstroComponent(result, displayName, Component, props, slots);
|
||||
}
|
||||
|
||||
return await renderFrameworkComponent(result, displayName, Component, props, slots);
|
||||
return await renderFrameworkComponent(result, displayName, Component, props, slots)
|
||||
.catch(handleCancellation);
|
||||
|
||||
function handleCancellation(e: unknown) {
|
||||
if (result.cancelled) return { render() {} };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProps(props: Record<string, any>): Record<string, any> {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import react from "@astrojs/react"
|
||||
|
||||
export default {
|
||||
integrations: [ react() ]
|
||||
}
|
|
@ -6,6 +6,9 @@
|
|||
"dev": "astro dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
"astro": "workspace:*",
|
||||
"@astrojs/react": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
||||
|
|
8
packages/astro/test/fixtures/streaming/src/components/react.tsx
vendored
Normal file
8
packages/astro/test/fixtures/streaming/src/components/react.tsx
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default function ReactComp({ foo }: { foo: { bar: { baz: string[] } } }) {
|
||||
return (
|
||||
<div>
|
||||
React Comp
|
||||
{foo.bar.baz.length}
|
||||
</div>
|
||||
);
|
||||
}
|
7
packages/astro/test/fixtures/streaming/src/pages/multiple-errors.astro
vendored
Normal file
7
packages/astro/test/fixtures/streaming/src/pages/multiple-errors.astro
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
import ReactComp from '../components/react.tsx';
|
||||
|
||||
const foo = { bar: null } as any;
|
||||
---
|
||||
<ReactComp foo={foo} />
|
||||
{foo.bar.baz.length > 0 && <div/>}
|
|
@ -2,11 +2,9 @@ import assert from 'node:assert/strict';
|
|||
import { after, before, describe, it } from 'node:test';
|
||||
import * as cheerio from 'cheerio';
|
||||
import testAdapter from './test-adapter.js';
|
||||
import { isWindows, loadFixture, streamAsyncIterator } from './test-utils.js';
|
||||
import { loadFixture, streamAsyncIterator } from './test-utils.js';
|
||||
|
||||
describe('Streaming', () => {
|
||||
if (isWindows) return;
|
||||
|
||||
/** @type {import('./test-utils').Fixture} */
|
||||
let fixture;
|
||||
|
||||
|
@ -79,12 +77,20 @@ describe('Streaming', () => {
|
|||
}
|
||||
assert.equal(chunks.length > 1, true);
|
||||
});
|
||||
|
||||
// if the offshoot promise goes unhandled, this test will pass immediately but fail the test suite
|
||||
it('Stays alive on failed component renders initiated by failed render templates', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
const request = new Request('http://example.com/multiple-errors');
|
||||
const response = await app.render(request);
|
||||
assert.equal(response.status, 500);
|
||||
const text = await response.text();
|
||||
assert.equal(text, '');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Streaming disabled', () => {
|
||||
if (isWindows) return;
|
||||
|
||||
/** @type {import('./test-utils').Fixture} */
|
||||
let fixture;
|
||||
|
||||
|
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
@ -3637,9 +3637,18 @@ importers:
|
|||
|
||||
packages/astro/test/fixtures/streaming:
|
||||
dependencies:
|
||||
'@astrojs/react':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../integrations/react
|
||||
astro:
|
||||
specifier: workspace:*
|
||||
version: link:../../..
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
|
||||
packages/astro/test/fixtures/svelte-component:
|
||||
dependencies:
|
||||
|
|
Loading…
Add table
Reference in a new issue