Skip to content

Commit 9b92141

Browse files
authored
Merge pull request #7259 from QwikDev/v2-retry-vnode-diff-on-promise-throw
fix: retry vnode diffing on promise throw
2 parents ad4444c + 3f4af53 commit 9b92141

File tree

8 files changed

+186
-40
lines changed

8 files changed

+186
-40
lines changed

.changeset/slimy-weeks-hope.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': patch
3+
---
4+
5+
fix: retry vnode diffing on promise throw

packages/qwik/src/core/shared/scheduler.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -345,11 +345,13 @@ export const createScheduler = (
345345
(jsx) => {
346346
if (chore.$type$ === ChoreType.COMPONENT) {
347347
const styleScopedId = container.getHostProp<string>(host, QScopedStyle);
348-
return vnode_diff(
349-
container as ClientContainer,
350-
jsx,
351-
host as VirtualVNode,
352-
addComponentStylePrefix(styleScopedId)
348+
return retryOnPromise(() =>
349+
vnode_diff(
350+
container as ClientContainer,
351+
jsx,
352+
host as VirtualVNode,
353+
addComponentStylePrefix(styleScopedId)
354+
)
353355
);
354356
} else {
355357
return jsx;
@@ -382,7 +384,9 @@ export const createScheduler = (
382384
if (isSignal(jsx)) {
383385
jsx = jsx.value as any;
384386
}
385-
returnValue = vnode_diff(container as DomContainer, jsx, parentVirtualNode, null);
387+
returnValue = retryOnPromise(() =>
388+
vnode_diff(container as DomContainer, jsx, parentVirtualNode, null)
389+
);
386390
break;
387391
case ChoreType.NODE_PROP:
388392
const virtualNode = chore.$host$ as unknown as ElementVNode;

packages/qwik/src/core/shared/utils/promises.ts

+17-4
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,26 @@ export const delay = (timeout: number) => {
106106
});
107107
};
108108

109-
export function retryOnPromise<T>(fn: () => T, retryCount: number = 0): ValueOrPromise<T> {
110-
try {
111-
return fn();
112-
} catch (e) {
109+
// Retries a function that throws a promise.
110+
export function retryOnPromise<T>(
111+
fn: () => ValueOrPromise<T>,
112+
retryCount: number = 0
113+
): ValueOrPromise<T> {
114+
const retryOrThrow = (e: any) => {
113115
if (isPromise(e) && retryCount < MAX_RETRY_ON_PROMISE_COUNT) {
114116
return e.then(retryOnPromise.bind(null, fn, retryCount++)) as ValueOrPromise<T>;
115117
}
116118
throw e;
119+
};
120+
121+
try {
122+
const result = fn();
123+
if (isPromise(result)) {
124+
// not awaited promise is not caught by try/catch block
125+
return result.catch((e) => retryOrThrow(e));
126+
}
127+
return result;
128+
} catch (e) {
129+
return retryOrThrow(e);
117130
}
118131
}

packages/qwik/src/core/signal/signal-subscriber.ts

+16
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
import { EffectSubscriptionsProp, WrappedSignal, isSignal, type Signal } from './signal';
1010
import type { Container } from '../shared/types';
1111
import { StoreHandler, getStoreHandler, isStore, type TargetType } from './store';
12+
import { isPropsProxy } from '../shared/jsx/jsx-runtime';
13+
import { _CONST_PROPS, _VAR_PROPS } from '../internal';
1214

1315
export abstract class Subscriber {
1416
$effectDependencies$: (Subscriber | TargetType)[] | null = null;
@@ -144,6 +146,20 @@ function clearArgEffect(arg: any, subscriber: Subscriber, seenSet: Set<unknown>)
144146
} else if (typeof arg === 'object' && arg !== null) {
145147
if (isStore(arg)) {
146148
clearStoreEffects(getStoreHandler(arg)!, subscriber);
149+
} else if (isPropsProxy(arg)) {
150+
// Separate check for props proxy, because props proxy getter could call signal getter.
151+
// To avoid that we need to get the constProps and varProps directly
152+
// from the props proxy object and loop over them.
153+
const constProps = arg[_CONST_PROPS];
154+
const varProps = arg[_VAR_PROPS];
155+
if (constProps) {
156+
for (const key in constProps) {
157+
clearArgEffect(constProps[key], subscriber, seenSet);
158+
}
159+
}
160+
for (const key in varProps) {
161+
clearArgEffect(varProps[key], subscriber, seenSet);
162+
}
147163
} else {
148164
for (const key in arg) {
149165
clearArgEffect(arg[key], subscriber, seenSet);

packages/qwik/src/core/tests/render-promise.spec.tsx

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment as Component, Fragment, component$ } from '@qwik.dev/core';
1+
import { Fragment as Component, Fragment, component$, useSignal } from '@qwik.dev/core';
22
import { domRender, ssrRenderToDom } from '@qwik.dev/core/testing';
33
import { describe, expect, it } from 'vitest';
44

@@ -24,4 +24,23 @@ describe.each([
2424
</Component>
2525
);
2626
});
27+
28+
it('should handle thrown Promise', async () => {
29+
const Child = component$(() => {
30+
const signal = useSignal(0);
31+
if (signal.value === 0) {
32+
throw new Promise((r) => r(signal.value++));
33+
}
34+
return 'child';
35+
});
36+
const Cmp = component$(() => {
37+
return (
38+
<div>
39+
<Child />
40+
</div>
41+
);
42+
});
43+
const { document } = await render(<Cmp />, { debug });
44+
expect(document.querySelector('div')).toHaveProperty('textContent', 'child');
45+
});
2746
});

starters/apps/e2e/src/components/attributes/attributes.e2e.tsx

-28
This file was deleted.

starters/apps/e2e/src/components/attributes/attributes.tsx

+95-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { component$, useSignal, useStore } from "@qwik.dev/core";
1+
import {
2+
component$,
3+
useComputed$,
4+
useSignal,
5+
useStore,
6+
type PropsOf,
7+
} from "@qwik.dev/core";
28

39
export const Attributes = component$(() => {
410
const render = useSignal(0);
@@ -13,6 +19,7 @@ export const Attributes = component$(() => {
1319
Rerender
1420
</button>
1521
<AttributesChild v={render.value} key={render.value} />
22+
<ProgressParent />
1623
</>
1724
);
1825
});
@@ -214,3 +221,90 @@ export const Issue4718Null = component$(() => {
214221
</button>
215222
);
216223
});
224+
225+
const ProgressRoot = component$<{ min?: number } & PropsOf<"div">>((props) => {
226+
const { ...rest } = props;
227+
228+
const minSig = useComputed$(() => props.min ?? 0);
229+
230+
const valueLabelSig = useComputed$(() => {
231+
const value = minSig.value;
232+
return `${value * 100}%`;
233+
});
234+
235+
return (
236+
<>
237+
<div id="progress-1" aria-valuetext={valueLabelSig.value} {...rest}>
238+
{valueLabelSig.value}
239+
</div>
240+
</>
241+
);
242+
});
243+
244+
const ProgressRootShowHide = component$<{ min: number } & PropsOf<"div">>(
245+
({ min, ...rest }) => {
246+
const show = useSignal(true);
247+
248+
return (
249+
<>
250+
{show.value && (
251+
<div id="progress-2" aria-valuetext={min.toString()} {...rest}>
252+
{min}
253+
</div>
254+
)}
255+
256+
<button id="progress-hide" onClick$={() => (show.value = !show.value)}>
257+
show/hide progress
258+
</button>
259+
</>
260+
);
261+
},
262+
);
263+
264+
const ProgressRootPromise = component$<{ min?: number } & PropsOf<"div">>(
265+
(props) => {
266+
const { ...rest } = props;
267+
268+
const minSig = useComputed$(() => props.min ?? 0);
269+
270+
const valueLabelSig = useComputed$(() => {
271+
const value = minSig.value;
272+
return `${value * 100}%`;
273+
});
274+
275+
return (
276+
<>
277+
{Promise.resolve(
278+
<div id="progress-3" aria-valuetext={valueLabelSig.value} {...rest}>
279+
{valueLabelSig.value}
280+
</div>,
281+
)}
282+
</>
283+
);
284+
},
285+
);
286+
287+
const ProgressParent = component$(() => {
288+
const minGoal = useSignal(2000);
289+
const computedGoal = useComputed$(() => minGoal.value + 100);
290+
291+
return (
292+
<div>
293+
<div>
294+
<span id="progress-value">${minGoal.value}</span>
295+
<button
296+
id="progress-btn"
297+
onClick$={() => {
298+
minGoal.value += 500;
299+
}}
300+
>
301+
+
302+
</button>
303+
</div>
304+
305+
<ProgressRoot min={minGoal.value} />
306+
<ProgressRootShowHide min={computedGoal.value} />
307+
<ProgressRootPromise min={minGoal.value} />
308+
</div>
309+
);
310+
});

starters/e2e/e2e.attributes.e2e.ts

+23
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,29 @@ test.describe("attributes", () => {
295295
await expect(button).not.toHaveAttribute("aria-label");
296296
await expect(button).not.toHaveAttribute("title");
297297
});
298+
299+
test("should rerun vnode-diff when QRL is not resolved", async ({
300+
page,
301+
}) => {
302+
const incrementButton = page.locator("#progress-btn");
303+
const hideButton = page.locator("#progress-hide");
304+
const progress1 = page.locator("#progress-1");
305+
const progress2 = page.locator("#progress-2");
306+
const progress3 = page.locator("#progress-3");
307+
308+
await expect(progress1).toHaveAttribute("aria-valuetext", "200000%");
309+
await expect(progress2).toHaveAttribute("aria-valuetext", "2100");
310+
await expect(progress3).toHaveAttribute("aria-valuetext", "200000%");
311+
312+
await hideButton.click();
313+
await hideButton.click();
314+
315+
await incrementButton.click();
316+
317+
await expect(progress1).toHaveAttribute("aria-valuetext", "250000%");
318+
await expect(progress2).toHaveAttribute("aria-valuetext", "2600");
319+
await expect(progress3).toHaveAttribute("aria-valuetext", "250000%");
320+
});
298321
}
299322

300323
tests();

0 commit comments

Comments
 (0)