Skip to content

Commit 9c4f0a9

Browse files
authored
Merge pull request #8715 from maiieul/fix-duplicate-projected-children
fix(core): reset creation mode when diffing into an existing projection
2 parents 82dc74d + 3014f12 commit 9c4f0a9

3 files changed

Lines changed: 83 additions & 7 deletions

File tree

.changeset/lucky-spiders-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': patch
3+
---
4+
5+
fix: duplicate projected element children when a component throws a promise on first render

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,7 @@ function diff(
409409
diffContext,
410410
diffContext.$jsxValue$.children,
411411
true,
412-
// special case for projection, we don't want to expect no children
413-
// because the projection's children are not removed
414-
false
412+
/* isProjection */ true
415413
);
416414
} else if (type === SSRComment) {
417415
expectNoMore(diffContext);
@@ -538,15 +536,17 @@ function advance(diffContext: DiffContext) {
538536
*
539537
* In the above example all nodes are on same level so we don't `descendVNode` even thought there is
540538
* an array produced by the `map` function.
539+
* @param isProjection - True when descending into a Projection, whose children survive re-renders:
540+
* they are diffed against (never re-created) and kept when the new JSX has no children.
541541
*/
542542
function descend(
543543
diffContext: DiffContext,
544544
children: JSXChildren,
545545
descendVNode: boolean,
546-
shouldExpectNoChildren: boolean = true
546+
isProjection: boolean = false
547547
) {
548548
if (
549-
shouldExpectNoChildren &&
549+
!isProjection &&
550550
(children == null || (descendVNode && isArray(children) && children.length === 0))
551551
) {
552552
expectNoChildren(diffContext);
@@ -560,7 +560,9 @@ function descend(
560560
'Expecting vCurrent to be defined.'
561561
);
562562
const creationMode =
563-
diffContext.$isCreationMode$ ||
563+
// a projection keeps its children across re-renders, so it never inherits
564+
// creation mode from a not-yet-rendered host; create only if new or empty
565+
(!isProjection && diffContext.$isCreationMode$) ||
564566
!!diffContext.$vNewNode$ ||
565567
!vnode_getFirstChild(diffContext.$vCurrent$!);
566568

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

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

@@ -43,4 +43,73 @@ describe.each([
4343
const { document } = await render(<Cmp />, { debug });
4444
expect(document.querySelector('div')).toHaveProperty('textContent', 'child');
4545
});
46+
47+
it('should not duplicate projected element children when component throws a promise on first render', async () => {
48+
const Wrapper = component$(() => (
49+
<form>
50+
<Slot />
51+
</form>
52+
));
53+
const Title = component$(() => <header>title</header>);
54+
const Footer = component$(() => <footer>footer</footer>);
55+
const Cmp = component$(() => {
56+
// The thrown promise writes a tracked signal, scheduling a second diff
57+
// pass before <Wrapper> has rendered its <Slot>. The trailing <Footer />
58+
// is required: matching it moves the cursor past the stale <div>, so
59+
// expectNoMore() would not clean it up.
60+
const store = useSignal<string>();
61+
if (!store.value) {
62+
throw Promise.resolve().then(() => {
63+
store.value = 'resolved';
64+
});
65+
}
66+
return (
67+
<Wrapper>
68+
<Title />
69+
<div class="fields">{store.value}</div>
70+
<Footer />
71+
</Wrapper>
72+
);
73+
});
74+
const { document } = await render(<Cmp />, { debug });
75+
expect(document.querySelectorAll('div.fields')).toHaveLength(1);
76+
expect(document.querySelectorAll('header')).toHaveLength(1);
77+
expect(document.querySelectorAll('footer')).toHaveLength(1);
78+
});
79+
80+
it('should not duplicate named slot content when component throws a promise on first render', async () => {
81+
const Wrapper = component$(() => (
82+
<form>
83+
<section>
84+
<Slot name="top" />
85+
</section>
86+
<Slot />
87+
</form>
88+
));
89+
const Title = component$(() => <header>title</header>);
90+
const Footer = component$(() => <footer>footer</footer>);
91+
const Cmp = component$(() => {
92+
const store = useSignal<string>();
93+
if (!store.value) {
94+
throw Promise.resolve().then(() => {
95+
store.value = 'resolved';
96+
});
97+
}
98+
return (
99+
<Wrapper>
100+
<div q:slot="top" class="banner">
101+
banner
102+
</div>
103+
<Title />
104+
<div class="fields">{store.value}</div>
105+
<Footer />
106+
</Wrapper>
107+
);
108+
});
109+
const { document } = await render(<Cmp />, { debug });
110+
expect(document.querySelectorAll('div.banner')).toHaveLength(1);
111+
expect(document.querySelectorAll('div.fields')).toHaveLength(1);
112+
expect(document.querySelectorAll('header')).toHaveLength(1);
113+
expect(document.querySelectorAll('footer')).toHaveLength(1);
114+
});
46115
});

0 commit comments

Comments
 (0)