Skip to content

Commit e116efc

Browse files
authored
Merge pull request #8117 from QwikDev/v2-attribute-promises
feat: support promises in attributes
2 parents d433c68 + a369eeb commit e116efc

File tree

13 files changed

+233
-79
lines changed

13 files changed

+233
-79
lines changed

.changeset/heavy-clouds-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': minor
3+
---
4+
5+
feat: support promises in attributes

packages/qwik/src/core/client/vnode-diff.ts

Lines changed: 88 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { JSXNodeImpl, isJSXNode } from '../shared/jsx/jsx-node';
1414
import { Fragment, type Props } from '../shared/jsx/jsx-runtime';
1515
import { directGetPropsProxyProp, type PropsProxy } from '../shared/jsx/props-proxy';
1616
import { Slot } from '../shared/jsx/slot.public';
17-
import type { JSXNodeInternal, JSXOutput } from '../shared/jsx/types/jsx-node';
17+
import type { JSXNodeInternal } from '../shared/jsx/types/jsx-node';
1818
import type { JSXChildren } from '../shared/jsx/types/jsx-qwik-attributes';
1919
import { SSRComment, SSRRaw, SkipRender } from '../shared/jsx/utils.public';
2020
import type { QRLInternal } from '../shared/qrl/qrl-class';
@@ -44,7 +44,7 @@ import {
4444
Q_PREFIX,
4545
dangerouslySetInnerHTML,
4646
} from '../shared/utils/markers';
47-
import { isPromise } from '../shared/utils/promises';
47+
import { isPromise, retryOnPromise } from '../shared/utils/promises';
4848
import { isSlotProp } from '../shared/utils/prop';
4949
import { hasClassAttr } from '../shared/utils/scoped-styles';
5050
import { serializeAttribute } from '../shared/utils/styles';
@@ -84,7 +84,7 @@ import { getAttributeNamespace, getNewElementNamespaceData } from './vnode-names
8484

8585
export const vnode_diff = (
8686
container: ClientContainer,
87-
jsxNode: JSXOutput,
87+
jsxNode: JSXChildren,
8888
vStartNode: VNode,
8989
scopedStyleIdPrefix: string | null
9090
) => {
@@ -98,8 +98,7 @@ export const vnode_diff = (
9898
*/
9999
const stack: any[] = [];
100100

101-
const asyncQueue: Array<VNode | ValueOrPromise<JSXOutput> | Promise<JSXOutput | JSXChildren>> =
102-
[];
101+
const asyncQueue: Array<VNode | ValueOrPromise<JSXChildren> | Promise<JSXChildren>> = [];
103102

104103
////////////////////////////////
105104
//// Traverse state variables
@@ -151,7 +150,7 @@ export const vnode_diff = (
151150
//////////////////////////////////////////////
152151
//////////////////////////////////////////////
153152

154-
function diff(jsxNode: JSXOutput, vStartNode: VNode) {
153+
function diff(jsxNode: JSXChildren, vStartNode: VNode) {
155154
assertFalse(vnode_isVNode(jsxNode), 'JSXNode should not be a VNode');
156155
assertTrue(vnode_isVNode(vStartNode), 'vStartNode should be a VNode');
157156
vParent = vStartNode as ElementVNode | VirtualVNode;
@@ -182,12 +181,10 @@ export const vnode_diff = (
182181
EffectSubscriptionProp.CONSUMER
183182
];
184183
if (currentSignal !== unwrappedSignal) {
184+
const vHost = (vNewNode || vCurrent)!;
185185
descend(
186-
trackSignalAndAssignHost(
187-
unwrappedSignal,
188-
(vNewNode || vCurrent)!,
189-
EffectProperty.VNODE,
190-
container
186+
resolveSignalAndDescend(() =>
187+
trackSignalAndAssignHost(unwrappedSignal, vHost, EffectProperty.VNODE, container)
191188
),
192189
true
193190
);
@@ -246,6 +243,21 @@ export const vnode_diff = (
246243
}
247244
}
248245

246+
function resolveSignalAndDescend(fn: () => ValueOrPromise<any>): ValueOrPromise<any> {
247+
try {
248+
return fn();
249+
} catch (e) {
250+
// Signal threw a promise (async computed signal) - handle retry and async queue
251+
if (isPromise(e)) {
252+
// The thrown promise will resolve when the signal is ready, then retry fn() with retry logic
253+
const retryPromise = e.then(() => retryOnPromise(fn));
254+
asyncQueue.push(retryPromise, vNewNode || vCurrent);
255+
return null;
256+
}
257+
throw e;
258+
}
259+
}
260+
249261
function advance() {
250262
if (!shouldAdvance) {
251263
shouldAdvance = true;
@@ -530,13 +542,19 @@ export const vnode_diff = (
530542

531543
function drainAsyncQueue(): ValueOrPromise<void> {
532544
while (asyncQueue.length) {
533-
const jsxNode = asyncQueue.shift() as ValueOrPromise<JSXNodeInternal>;
545+
const jsxNode = asyncQueue.shift() as ValueOrPromise<JSXChildren>;
534546
const vHostNode = asyncQueue.shift() as VNode;
547+
535548
if (isPromise(jsxNode)) {
536-
return jsxNode.then((jsxNode) => {
537-
diff(jsxNode, vHostNode);
538-
return drainAsyncQueue();
539-
});
549+
return jsxNode
550+
.then((jsxNode) => {
551+
diff(jsxNode, vHostNode);
552+
return drainAsyncQueue();
553+
})
554+
.catch((e) => {
555+
container.handleError(e, vHostNode);
556+
return drainAsyncQueue();
557+
});
540558
} else {
541559
diff(jsxNode, vHostNode);
542560
}
@@ -594,6 +612,21 @@ export const vnode_diff = (
594612
): boolean {
595613
const element = createElementWithNamespace(elementName);
596614

615+
function setAttribute(key: string, value: any, vHost: ElementVNode) {
616+
value = serializeAttribute(key, value, scopedStyleIdPrefix);
617+
if (value != null) {
618+
if (vHost.flags & VNodeFlags.NS_svg) {
619+
// only svg elements can have namespace attributes
620+
const namespace = getAttributeNamespace(key);
621+
if (namespace) {
622+
element.setAttributeNS(namespace, key, String(value));
623+
return;
624+
}
625+
}
626+
element.setAttribute(key, String(value));
627+
}
628+
}
629+
597630
const { constProps } = jsx;
598631
let needsQDispatchEventPatch = false;
599632
if (constProps) {
@@ -637,15 +670,19 @@ export const vnode_diff = (
637670
}
638671

639672
if (isSignal(value)) {
640-
value = trackSignalAndAssignHost(
641-
value as Signal<unknown>,
642-
vNewNode as ElementVNode,
643-
key,
644-
container,
645-
CONST_SUBSCRIPTION_DATA
673+
const vHost = vNewNode as ElementVNode;
674+
const signal = value as Signal<unknown>;
675+
value = retryOnPromise(() =>
676+
trackSignalAndAssignHost(signal, vHost, key, container, CONST_SUBSCRIPTION_DATA)
646677
);
647678
}
648679

680+
if (isPromise(value)) {
681+
const vHost = vNewNode as ElementVNode;
682+
value.then((resolvedValue) => setAttribute(key, resolvedValue, vHost));
683+
continue;
684+
}
685+
649686
if (key === dangerouslySetInnerHTML) {
650687
if (value) {
651688
element.innerHTML = String(value);
@@ -665,18 +702,7 @@ export const vnode_diff = (
665702
continue;
666703
}
667704

668-
value = serializeAttribute(key, value, scopedStyleIdPrefix);
669-
if (value != null) {
670-
if (vNewNode!.flags & VNodeFlags.NS_svg) {
671-
// only svg elements can have namespace attributes
672-
const namespace = getAttributeNamespace(key);
673-
if (namespace) {
674-
element.setAttributeNS(namespace, key, String(value));
675-
continue;
676-
}
677-
}
678-
element.setAttribute(key, String(value));
679-
}
705+
setAttribute(key, value, vNewNode as ElementVNode);
680706
}
681707
}
682708
const key = jsx.key;
@@ -803,6 +829,14 @@ export const vnode_diff = (
803829
let dstIdx = 0;
804830
let patchEventDispatch = false;
805831

832+
const setAttribute = (key: string, value: any, vHost: ElementVNode) => {
833+
vHost.setAttr(
834+
key,
835+
value !== null ? serializeAttribute(key, value, scopedStyleIdPrefix) : null,
836+
journal
837+
);
838+
};
839+
806840
const record = (key: string, value: any) => {
807841
if (key.startsWith(':')) {
808842
vnode.setProp(key, value);
@@ -837,12 +871,16 @@ export const vnode_diff = (
837871
// Only if we want to track the signal again
838872
clearEffectSubscription(container, currentEffect);
839873
}
840-
value = trackSignalAndAssignHost(
841-
unwrappedSignal,
842-
vnode,
843-
key,
844-
container,
845-
NON_CONST_SUBSCRIPTION_DATA
874+
875+
const vHost = vnode as ElementVNode;
876+
value = retryOnPromise(() =>
877+
trackSignalAndAssignHost(
878+
unwrappedSignal,
879+
vHost,
880+
key,
881+
container,
882+
NON_CONST_SUBSCRIPTION_DATA
883+
)
846884
);
847885
} else {
848886
if (currentEffect) {
@@ -853,11 +891,13 @@ export const vnode_diff = (
853891
}
854892
}
855893

856-
vnode.setAttr(
857-
key,
858-
value !== null ? serializeAttribute(key, value, scopedStyleIdPrefix) : null,
859-
journal
860-
);
894+
if (isPromise(value)) {
895+
const vHost = vnode as ElementVNode;
896+
value.then((resolvedValue) => setAttribute(key, resolvedValue, vHost));
897+
return;
898+
}
899+
900+
setAttribute(key, value, vnode);
861901
};
862902

863903
const recordJsxEvent = (key: string, value: any) => {
@@ -1113,11 +1153,8 @@ export const vnode_diff = (
11131153
function expectVirtual(type: VirtualType, jsxKey: string | null) {
11141154
const checkKey = type === VirtualType.Fragment;
11151155
const currentKey = getKey(vCurrent);
1116-
const isSameNode =
1117-
vCurrent &&
1118-
vnode_isVirtualVNode(vCurrent) &&
1119-
currentKey === jsxKey &&
1120-
(checkKey ? !!jsxKey : true);
1156+
const currentIsVirtual = vCurrent && vnode_isVirtualVNode(vCurrent);
1157+
const isSameNode = currentIsVirtual && currentKey === jsxKey && (checkKey ? !!jsxKey : true);
11211158

11221159
if (isSameNode) {
11231160
// All is good.
@@ -1136,7 +1173,7 @@ export const vnode_diff = (
11361173
isDev && (vNewNode as VirtualVNode).setProp(DEBUG_TYPE, type);
11371174
};
11381175
// For fragments without a key, always create a new virtual node (ensures rerender semantics)
1139-
if (checkKey && jsxKey === null) {
1176+
if (jsxKey === null) {
11401177
createNew();
11411178
return;
11421179
}

packages/qwik/src/core/client/vnode-diff.unit.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, _fnSignal, _jsxSorted, component$ } from '@qwik.dev/core';
1+
import { Fragment, _fnSignal, _jsxSorted, component$, type JSXOutput } from '@qwik.dev/core';
22
import { vnode_fromJSX } from '@qwik.dev/core/testing';
33
import { describe, expect, it } from 'vitest';
44
import type { SignalImpl } from '../reactive-primitives/impl/signal-impl';
@@ -534,6 +534,19 @@ describe('vNode-diff', () => {
534534
expect(vNode).toMatchVDOM(test);
535535
expect(fragment).not.toBe(vnode_getFirstChild(vNode!));
536536
});
537+
538+
it('should render fragment if only text was available', async () => {
539+
const { vParent, container } = vnode_fromJSX('1');
540+
const test = Promise.resolve('2') as unknown as JSXOutput; //_jsxSorted(Fragment, {}, null, ['1'], 0, null);
541+
542+
await vnode_diff(container, test, vParent, null);
543+
vnode_applyJournal(container.$journal$);
544+
expect(vParent).toMatchVDOM(
545+
<body>
546+
<Fragment>2</Fragment>
547+
</body>
548+
);
549+
});
537550
});
538551
describe('attributes', () => {
539552
describe('const props', () => {

packages/qwik/src/core/ssr/ssr-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { SsrNodeFlags } from '../shared/types';
1616
import type { ResourceReturnInternal } from '../use/use-resource';
1717

1818
export type SsrAttrKey = string;
19-
export type SsrAttrValue = string | Signal<any> | boolean | object | null;
19+
export type SsrAttrValue = string | Signal<any> | Promise<any> | boolean | object | null;
2020
export type SsrAttrs = Array<SsrAttrKey | SsrAttrValue>;
2121

2222
/** @internal */

packages/qwik/src/core/tests/backpatch.spec.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,6 @@ import { ELEMENT_BACKPATCH_DATA } from '../../server/qwik-copy';
1717
const debug = false; //true;
1818
Error.stackTraceLimit = 100;
1919

20-
vi.hoisted(() => {
21-
vi.stubGlobal('QWIK_BACKPATCH_EXECUTOR_MINIFIED', 'min');
22-
vi.stubGlobal('QWIK_BACKPATCH_EXECUTOR_DEBUG', 'debug');
23-
});
24-
2520
describe('SSR Backpatching', () => {
2621
it('should handle basic backpatching', async () => {
2722
const Ctx = createContextId<{ descId: Signal<string> }>('bp-ctx-1');

packages/qwik/src/core/tests/use-async-computed.spec.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { Fragment as Signal, component$, useSignal, useTask$ } from '@qwik.dev/core';
1+
import {
2+
$,
3+
Fragment as Signal,
4+
_jsxSorted,
5+
_wrapProp,
6+
component$,
7+
useSignal,
8+
useTask$,
9+
} from '@qwik.dev/core';
210
import { domRender, ssrRenderToDom, trigger, waitForDrain } from '@qwik.dev/core/testing';
311
import { describe, expect, it } from 'vitest';
412
import { useAsyncComputed$ } from '../use/use-async-computed';
@@ -129,6 +137,57 @@ describe.each([
129137
);
130138
});
131139

140+
it('should render as attribute', async () => {
141+
const Counter = component$(() => {
142+
const count = useSignal(1);
143+
const doubleCount = useAsyncComputed$(({ track }) => Promise.resolve(track(count) * 2));
144+
return <button data-count={doubleCount.value} onClick$={() => count.value++}></button>;
145+
});
146+
const { vNode, container } = await render(<Counter />, { debug });
147+
expect(vNode).toMatchVDOM(
148+
<>
149+
<button data-count="2"></button>
150+
</>
151+
);
152+
await trigger(container.element, 'button', 'click');
153+
expect(vNode).toMatchVDOM(
154+
<>
155+
<button data-count="4"></button>
156+
</>
157+
);
158+
});
159+
160+
it('should render var prop as attribute', async () => {
161+
const Counter = component$(() => {
162+
const count = useSignal(1);
163+
const doubleCount = useAsyncComputed$(({ track }) => Promise.resolve(track(count) * 2));
164+
return _jsxSorted(
165+
'button',
166+
{
167+
'data-count': _wrapProp(doubleCount, 'value'),
168+
onClick$: $(() => count.value++),
169+
},
170+
null,
171+
null,
172+
0,
173+
null,
174+
undefined
175+
);
176+
});
177+
const { vNode, container } = await render(<Counter />, { debug });
178+
expect(vNode).toMatchVDOM(
179+
<>
180+
<button data-count="2"></button>
181+
</>
182+
);
183+
await trigger(container.element, 'button', 'click');
184+
expect(vNode).toMatchVDOM(
185+
<>
186+
<button data-count="4"></button>
187+
</>
188+
);
189+
});
190+
132191
describe('loading', () => {
133192
it('should show loading state', async () => {
134193
(globalThis as any).delay = () =>

0 commit comments

Comments
 (0)