Skip to content

Commit a9ecda4

Browse files
Copilotleonidaztrueadmcursoragent
authored
Fix catch block not executing when used with pending block in try statements (#742)
* Initial plan * fix: catch block not executing when used with pending block in try statements Fix async error propagation in client runtime by catching rejected promises in the async() block function and routing them through handle_error. Clean up pending/suspended state in try.js handle_error when error occurs during pending display. Fix server transform to remove pending content from output when async block resolves or errors. Co-authored-by: leonidaz <329182+leonidaz@users.noreply.github.com> * fix: format changeset markdown to pass prettier check Co-authored-by: trueadm <1519870+trueadm@users.noreply.github.com> * fix(server): avoid nested pending var collisions Co-authored-by: Dominic Gannaway <trueadm@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: leonidaz <329182+leonidaz@users.noreply.github.com> Co-authored-by: trueadm <1519870+trueadm@users.noreply.github.com> Co-authored-by: Dominic Gannaway <trueadm@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 6142351 commit a9ecda4

6 files changed

Lines changed: 214 additions & 3 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'ripple': patch
3+
---
4+
5+
Fix catch block not executing when used with pending block in try statements.
6+
Previously, errors thrown inside async components within
7+
`try { ... } pending { ... } catch { ... }` blocks were lost as unhandled promise
8+
rejections. Now errors are properly caught and the catch block is rendered. Also
9+
fixes the server-side rendering to not include pending content in the final output
10+
when the async operation resolves or errors.

packages/ripple/src/compiler/phases/3-transform/server/index.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1369,7 +1369,11 @@ const visitors = {
13691369
context.state.metadata.await = true;
13701370
}
13711371

1372-
// Render pending block first
1372+
const pending_position_name = node.pending
1373+
? context.state.scope.generate('__pending_pos')
1374+
: null;
1375+
1376+
// Render pending block first, saving position so we can remove it after async resolves
13731377
if (node.pending) {
13741378
const pending_body = transform_body(node.pending.body, {
13751379
...context,
@@ -1378,6 +1382,12 @@ const visitors = {
13781382
scope: /** @type {ScopeInterface} */ (context.state.scopes.get(node.pending)),
13791383
},
13801384
});
1385+
context.state.init?.push(
1386+
b.var(
1387+
b.id(pending_position_name),
1388+
b.member(b.member(b.id('__output'), b.id('body')), b.id('length')),
1389+
),
1390+
);
13811391
context.state.init?.push(...pending_body);
13821392
}
13831393

@@ -1406,6 +1416,24 @@ const visitors = {
14061416
];
14071417
}
14081418

1419+
// Remove pending content before rendering resolved/catch content
1420+
if (node.pending) {
1421+
try_statements = [
1422+
b.stmt(
1423+
b.assignment(
1424+
'=',
1425+
b.member(b.id('__output'), b.id('body')),
1426+
b.call(
1427+
b.member(b.member(b.id('__output'), b.id('body')), b.id('slice')),
1428+
b.literal(0),
1429+
b.id(pending_position_name),
1430+
),
1431+
),
1432+
),
1433+
...try_statements,
1434+
];
1435+
}
1436+
14091437
context.state.init?.push(
14101438
b.stmt(b.await(b.call('_$_.async', b.thunk(b.block(try_statements), true)))),
14111439
);

packages/ripple/src/runtime/internal/client/blocks.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
active_component,
2121
active_reaction,
2222
create_component_ctx,
23+
handle_error,
2324
is_block_dirty,
2425
run_block,
2526
run_teardown,
@@ -91,9 +92,14 @@ export function branch(fn, flags = 0, state = null) {
9192
*/
9293
export function async(fn) {
9394
return block(BRANCH_BLOCK, async () => {
95+
var current_block = active_block;
9496
const unsuspend = suspend();
95-
await fn();
96-
unsuspend();
97+
try {
98+
await fn();
99+
unsuspend();
100+
} catch (error) {
101+
handle_error(error, /** @type {Block} */ (current_block));
102+
}
97103
});
98104
}
99105

packages/ripple/src/runtime/internal/client/try.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ export function try_block(node, fn, catch_fn, pending_fn = null) {
8787
* @returns {void}
8888
*/
8989
function handle_error(error) {
90+
if (suspended !== null) {
91+
destroy_block(suspended);
92+
suspended = null;
93+
offscreen_fragment = null;
94+
pending_count = 0;
95+
}
96+
9097
if (b !== null) {
9198
destroy_block(b);
9299
}

packages/ripple/tests/client/try.test.ripple

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,80 @@
11
import { flushSync, track, TrackedArray } from 'ripple';
22

3+
describe('try block with catch and pending', () => {
4+
it('catch block works when component throws before await with pending block', async () => {
5+
component App() {
6+
try {
7+
<ThrowingChild />
8+
} pending {
9+
<p>{'loading...'}</p>
10+
} catch (err) {
11+
<p>{'caught error'}</p>
12+
}
13+
}
14+
15+
component ThrowingChild() {
16+
throw new Error('sync error');
17+
let data = await Promise.resolve('hello');
18+
<p>{data}</p>
19+
}
20+
21+
render(App);
22+
23+
await new Promise((resolve) => setTimeout(resolve, 0));
24+
flushSync();
25+
26+
expect(container.innerHTML).toContain('caught error');
27+
expect(container.innerHTML).not.toContain('loading...');
28+
});
29+
30+
it('catch block works when component throws after await with pending block', async () => {
31+
component App() {
32+
try {
33+
<ThrowingAfterAwait />
34+
} pending {
35+
<p>{'loading...'}</p>
36+
} catch (err) {
37+
<p>{'caught error'}</p>
38+
}
39+
}
40+
41+
component ThrowingAfterAwait() {
42+
let data = await Promise.resolve('hello');
43+
throw new Error('error after await');
44+
<p>{data}</p>
45+
}
46+
47+
render(App);
48+
49+
await new Promise((resolve) => setTimeout(resolve, 0));
50+
flushSync();
51+
52+
expect(container.innerHTML).toContain('caught error');
53+
expect(container.innerHTML).not.toContain('loading...');
54+
});
55+
56+
it('catch block works with try/catch/pending when async body rejects', async () => {
57+
component App() {
58+
try {
59+
let data = await Promise.reject(new Error('rejected'));
60+
<p>{data}</p>
61+
} pending {
62+
<p>{'loading...'}</p>
63+
} catch (err) {
64+
<p>{'caught rejection'}</p>
65+
}
66+
}
67+
68+
render(App);
69+
70+
await new Promise((resolve) => setTimeout(resolve, 0));
71+
flushSync();
72+
73+
expect(container.innerHTML).toContain('caught rejection');
74+
expect(container.innerHTML).not.toContain('loading...');
75+
});
76+
});
77+
378
describe('try block', () => {
479
it('does not crash when async component is used inside try/pending', async () => {
580
component App() {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render } from 'ripple/server';
3+
4+
describe('try block with catch and pending (server)', () => {
5+
it('catch block works when component throws before await with pending block', async () => {
6+
component ThrowingChild() {
7+
throw new Error('sync error');
8+
let data = await Promise.resolve('hello');
9+
<p>{data}</p>
10+
}
11+
12+
component App() {
13+
try {
14+
<ThrowingChild />
15+
} pending {
16+
<p>{'loading...'}</p>
17+
} catch (err) {
18+
<p>{'caught error'}</p>
19+
}
20+
}
21+
22+
const { body } = await render(App);
23+
expect(body).toContain('caught error');
24+
expect(body).not.toContain('loading...');
25+
});
26+
27+
it('catch block works when component throws after await with pending block', async () => {
28+
component ThrowingAfterAwait() {
29+
let data = await Promise.resolve('hello');
30+
throw new Error('error after await');
31+
<p>{data}</p>
32+
}
33+
34+
component App() {
35+
try {
36+
<ThrowingAfterAwait />
37+
} pending {
38+
<p>{'loading...'}</p>
39+
} catch (err) {
40+
<p>{'caught error'}</p>
41+
}
42+
}
43+
44+
const { body } = await render(App);
45+
expect(body).toContain('caught error');
46+
expect(body).not.toContain('loading...');
47+
});
48+
49+
it('catch block works with try/catch/pending when async body rejects', async () => {
50+
component App() {
51+
try {
52+
let data = await Promise.reject(new Error('rejected'));
53+
<p>{data}</p>
54+
} pending {
55+
<p>{'loading...'}</p>
56+
} catch (err) {
57+
<p>{'caught rejection'}</p>
58+
}
59+
}
60+
61+
const { body } = await render(App);
62+
expect(body).toContain('caught rejection');
63+
expect(body).not.toContain('loading...');
64+
});
65+
66+
it('removes pending content for nested try/pending blocks', async () => {
67+
component App() {
68+
try {
69+
try {
70+
let data = await Promise.resolve('resolved');
71+
<p>{data}</p>
72+
} pending {
73+
<p>{'inner loading...'}</p>
74+
}
75+
} pending {
76+
<p>{'outer loading...'}</p>
77+
}
78+
}
79+
80+
const { body } = await render(App);
81+
expect(body).toContain('resolved');
82+
expect(body).not.toContain('outer loading...');
83+
expect(body).not.toContain('inner loading...');
84+
});
85+
});

0 commit comments

Comments
 (0)