Skip to content

Commit 5f1c26b

Browse files
committed
feat: support promises as attributes
1 parent 6795710 commit 5f1c26b

File tree

10 files changed

+211
-70
lines changed

10 files changed

+211
-70
lines changed

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

Lines changed: 95 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,15 @@ import {
3939
QBackRefs,
4040
QContainerAttr,
4141
QDefaultSlot,
42+
QScopedStyle,
4243
QSlot,
4344
QTemplate,
4445
Q_PREFIX,
4546
dangerouslySetInnerHTML,
4647
} from '../shared/utils/markers';
47-
import { isPromise } from '../shared/utils/promises';
48+
import { isPromise, retryOnPromise } from '../shared/utils/promises';
4849
import { isSlotProp } from '../shared/utils/prop';
49-
import { hasClassAttr } from '../shared/utils/scoped-styles';
50+
import { addComponentStylePrefix, hasClassAttr } from '../shared/utils/scoped-styles';
5051
import { serializeAttribute } from '../shared/utils/styles';
5152
import { isArray, type ValueOrPromise } from '../shared/utils/types';
5253
import { trackSignalAndAssignHost } from '../use/use-core';
@@ -182,19 +183,24 @@ export const vnode_diff = (
182183
EffectSubscriptionProp.CONSUMER
183184
];
184185
if (currentSignal !== unwrappedSignal) {
186+
const vHost = (vNewNode || vCurrent)!;
185187
descend(
186-
trackSignalAndAssignHost(
187-
unwrappedSignal,
188-
(vNewNode || vCurrent)!,
189-
EffectProperty.VNODE,
190-
container
188+
resolveSignalAndDescend(
189+
retryOnPromise(() =>
190+
trackSignalAndAssignHost(
191+
unwrappedSignal,
192+
vHost,
193+
EffectProperty.VNODE,
194+
container
195+
)
196+
)
191197
),
192198
true
193199
);
194200
}
195201
} else if (isPromise(jsxValue)) {
196202
expectVirtual(VirtualType.Awaited, null);
197-
asyncQueue.push(jsxValue, vNewNode || vCurrent);
203+
asyncQueue.push(jsxValue, vNewNode || vCurrent, null);
198204
} else if (isJSXNode(jsxValue)) {
199205
const type = jsxValue.type;
200206
if (typeof type === 'string') {
@@ -246,6 +252,14 @@ export const vnode_diff = (
246252
}
247253
}
248254

255+
function resolveSignalAndDescend(value: any) {
256+
if (isPromise(value)) {
257+
asyncQueue.push(value, vNewNode || vCurrent, null);
258+
return null;
259+
}
260+
return value;
261+
}
262+
249263
function advance() {
250264
if (!shouldAdvance) {
251265
shouldAdvance = true;
@@ -532,13 +546,27 @@ export const vnode_diff = (
532546
while (asyncQueue.length) {
533547
const jsxNode = asyncQueue.shift() as ValueOrPromise<JSXNodeInternal>;
534548
const vHostNode = asyncQueue.shift() as VNode;
549+
const styleScopedId = asyncQueue.shift() as string | null;
535550
if (isPromise(jsxNode)) {
536-
return jsxNode.then((jsxNode) => {
537-
diff(jsxNode, vHostNode);
538-
return drainAsyncQueue();
539-
});
551+
return jsxNode
552+
.then((jsxNode) => {
553+
if (styleScopedId) {
554+
vnode_diff(container, jsxNode, vHostNode, addComponentStylePrefix(styleScopedId));
555+
} else {
556+
diff(jsxNode, vHostNode);
557+
}
558+
return drainAsyncQueue();
559+
})
560+
.catch((e) => {
561+
container.handleError(e, vHostNode);
562+
return drainAsyncQueue();
563+
});
540564
} else {
541-
diff(jsxNode, vHostNode);
565+
if (styleScopedId) {
566+
vnode_diff(container, jsxNode, vHostNode, addComponentStylePrefix(styleScopedId));
567+
} else {
568+
diff(jsxNode, vHostNode);
569+
}
542570
}
543571
}
544572
}
@@ -594,6 +622,21 @@ export const vnode_diff = (
594622
): boolean {
595623
const element = createElementWithNamespace(elementName);
596624

625+
function setAttribute(key: string, value: any, vHost: ElementVNode) {
626+
value = serializeAttribute(key, value, scopedStyleIdPrefix);
627+
if (value != null) {
628+
if (vHost.flags & VNodeFlags.NS_svg) {
629+
// only svg elements can have namespace attributes
630+
const namespace = getAttributeNamespace(key);
631+
if (namespace) {
632+
element.setAttributeNS(namespace, key, String(value));
633+
return;
634+
}
635+
}
636+
element.setAttribute(key, String(value));
637+
}
638+
}
639+
597640
const { constProps } = jsx;
598641
let needsQDispatchEventPatch = false;
599642
if (constProps) {
@@ -637,15 +680,19 @@ export const vnode_diff = (
637680
}
638681

639682
if (isSignal(value)) {
640-
value = trackSignalAndAssignHost(
641-
value as Signal<unknown>,
642-
vNewNode as ElementVNode,
643-
key,
644-
container,
645-
CONST_SUBSCRIPTION_DATA
683+
const vHost = vNewNode as ElementVNode;
684+
const signal = value as Signal<unknown>;
685+
value = retryOnPromise(() =>
686+
trackSignalAndAssignHost(signal, vHost, key, container, CONST_SUBSCRIPTION_DATA)
646687
);
647688
}
648689

690+
if (isPromise(value)) {
691+
const vHost = vNewNode as ElementVNode;
692+
value.then((resolvedValue) => setAttribute(key, resolvedValue, vHost));
693+
continue;
694+
}
695+
649696
if (key === dangerouslySetInnerHTML) {
650697
if (value) {
651698
element.innerHTML = String(value);
@@ -665,18 +712,7 @@ export const vnode_diff = (
665712
continue;
666713
}
667714

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-
}
715+
setAttribute(key, value, vNewNode as ElementVNode);
680716
}
681717
}
682718
const key = jsx.key;
@@ -803,6 +839,14 @@ export const vnode_diff = (
803839
let dstIdx = 0;
804840
let patchEventDispatch = false;
805841

842+
const setAttribute = (key: string, value: any, vHost: ElementVNode) => {
843+
vHost.setAttr(
844+
key,
845+
value !== null ? serializeAttribute(key, value, scopedStyleIdPrefix) : null,
846+
journal
847+
);
848+
};
849+
806850
const record = (key: string, value: any) => {
807851
if (key.startsWith(':')) {
808852
vnode.setProp(key, value);
@@ -837,12 +881,16 @@ export const vnode_diff = (
837881
// Only if we want to track the signal again
838882
clearEffectSubscription(container, currentEffect);
839883
}
840-
value = trackSignalAndAssignHost(
841-
unwrappedSignal,
842-
vnode,
843-
key,
844-
container,
845-
NON_CONST_SUBSCRIPTION_DATA
884+
885+
const vHost = vnode as ElementVNode;
886+
value = retryOnPromise(() =>
887+
trackSignalAndAssignHost(
888+
unwrappedSignal,
889+
vHost,
890+
key,
891+
container,
892+
NON_CONST_SUBSCRIPTION_DATA
893+
)
846894
);
847895
} else {
848896
if (currentEffect) {
@@ -853,11 +901,13 @@ export const vnode_diff = (
853901
}
854902
}
855903

856-
vnode.setAttr(
857-
key,
858-
value !== null ? serializeAttribute(key, value, scopedStyleIdPrefix) : null,
859-
journal
860-
);
904+
if (isPromise(value)) {
905+
const vHost = vnode as ElementVNode;
906+
value.then((resolvedValue) => setAttribute(key, resolvedValue, vHost));
907+
return;
908+
}
909+
910+
setAttribute(key, value, vnode);
861911
};
862912

863913
const recordJsxEvent = (key: string, value: any) => {
@@ -1169,6 +1219,8 @@ export const vnode_diff = (
11691219
const lookupKeysAreEqual = lookupKey === vNodeLookupKey;
11701220
const hashesAreEqual = componentHash === vNodeComponentHash;
11711221

1222+
const jsxChildren = jsxNode.children;
1223+
11721224
if (!lookupKeysAreEqual) {
11731225
const createNew = () => {
11741226
insertNewComponent(host, componentQRL, jsxProps);
@@ -1280,7 +1332,7 @@ export const vnode_diff = (
12801332
jsxNode.props
12811333
);
12821334

1283-
asyncQueue.push(jsxOutput, host);
1335+
asyncQueue.push(jsxOutput, host, null);
12841336
}
12851337
}
12861338
}

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 = () =>

packages/qwik/src/core/tests/use-task.spec.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -598,11 +598,7 @@ describe.each([
598598
expect(vNode).toMatchVDOM(
599599
<Component>
600600
<p>
601-
Should have a number: "
602-
<Fragment>
603-
<Signal>3</Signal>
604-
</Fragment>
605-
"
601+
Should have a number: "<Fragment>3</Fragment>"
606602
</p>
607603
</Component>
608604
);

packages/qwik/src/server/qwik-copy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,4 @@ export { VNodeDataChar, VNodeDataSeparator } from '../core/shared/vnode-data-typ
6565
export { getQueue, preload, resetQueue } from '../core/preloader/queue';
6666
export { initPreloader } from '../core/preloader/bundle-graph';
6767
export { SsrNodeFlags } from '../core/shared/types';
68+
export { isPromise, retryOnPromise } from '../core/shared/utils/promises';

0 commit comments

Comments
 (0)