Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/keyed-shared-text-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': patch
---

fix: keyed list re-render after resume no longer duplicates or drops the SSR-shared text of keyed fragments
20 changes: 14 additions & 6 deletions packages/qwik/src/core/client/vnode-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,15 +556,15 @@ function registerQrlHandlers(attr: Attr, key: string, container: Container, elem
export function vnode_walkDirectChildren(
journal: VNodeJournal,
vParent: VNode,
callback: (vNode: ElementVNode | TextVNode, vParent: VNode | null) => boolean | void
callback?: (vNode: ElementVNode | TextVNode, vParent: VNode | null) => boolean | void
): void {
let vNode = vnode_getFirstChild(vParent);
while (vNode) {
if (vnode_isTextVNode(vNode)) {
vnode_ensureTextInflated(journal, vNode);
callback(vNode, vParent);
callback?.(vNode, vParent);
} else if (vnode_isElementVNode(vNode)) {
callback(vNode, vParent);
callback?.(vNode, vParent);
} else {
// for virtual nodes, we need to walk their children
vnode_walkDirectChildren(journal, vNode, callback);
Expand Down Expand Up @@ -759,8 +759,11 @@ export const vnode_ensureTextInflated = (journal: VNodeJournal, vnode: TextVNode
| Text
| null);

// Only text vnodes that still point at the same SSR Text node belong to this run. A keyed diff can make text
// vnodes from two different SSR Text nodes adjacent (e.g. it removed the element that separated them), and
// splitting across them duplicates or overwrites text.
let lastPreviousTextNode = insertBeforeNode;
while (vCursor && vnode_isTextVNode(vCursor)) {
while (vCursor && vnode_isTextVNode(vCursor) && vCursor.node === sharedTextNode) {
if ((vCursor.flags & VNodeFlags.Inflated) === 0) {
const textNode = doc.createTextNode(vCursor.text!);
addVNodeOperation(
Expand All @@ -775,9 +778,9 @@ export const vnode_ensureTextInflated = (journal: VNodeJournal, vnode: TextVNode
}
// Walk the next siblings and inflate them.
vCursor = vnode;
while (vCursor && vnode_isTextVNode(vCursor)) {
while (vCursor && vnode_isTextVNode(vCursor) && vCursor.node === sharedTextNode) {
const next = vnode_getDomSibling(vCursor, true, true);
const isLastNode = next ? !vnode_isTextVNode(next) : true;
const isLastNode = !next || !vnode_isTextVNode(next) || next.node !== sharedTextNode;
if ((vCursor.flags & VNodeFlags.Inflated) === 0) {
if (isLastNode && sharedTextNode) {
addVNodeOperation(journal, createSetTextOperation(sharedTextNode, vCursor.text!));
Expand Down Expand Up @@ -1121,6 +1124,11 @@ export const vnode_insertVirtualBefore = (
}
}

if (newChildCurrentParent) {
// Split SSR-shared text while the virtual is still linked to its siblings. Once unlinked, its last text vnode
// looks like the end of the run and takes over the shared Text node, dropping the text of the others.
vnode_walkDirectChildren(journal, newChild);
}
vnode_unlinkFromOldParent(journal, newChildCurrentParent, parent, newChild);

const parentIsDeleted = parent.flags & VNodeFlags.Deleted;
Expand Down
82 changes: 82 additions & 0 deletions packages/qwik/src/core/tests/render-regression.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import '../../testing/vdom-diff.unit-util';
const debug = false; //true;
Error.stackTraceLimit = 100;

const Placeholder = component$(() => <div />);
const Chord = (props: { children: string }) => <b>{props.children}</b>;

describe.each([
{ render: ssrRenderToDom }, //
{ render: domRender }, //
Expand Down Expand Up @@ -373,4 +376,83 @@ describe.each([
expect(document.querySelector('#count')!.textContent).toBe('1');
});
});

describe('keyed list: SSR-shared text of keyed fragments after a keyed swap', () => {
// SSR writes the text of adjacent keyed fragments ("t1", "t2") as one DOM Text node. Swapping the keyed
// placeholders around them must not duplicate, overwrite or drop that text after resume.
it('element placeholders, text after the swapped items', async () => {
const Cmp = component$(() => {
const done = useSignal(false);
return (
<div>
<button onClick$={() => (done.value = true)} />
<section>
{done.value ? <b key="c0">X0</b> : <i key="c0" />}
<Fragment key="t1">t1</Fragment>
<Fragment key="t2">t2</Fragment>
{done.value ? <b key="c3">X3</b> : <i key="c3" />}
<Fragment key="t4">t4</Fragment>
</section>
</div>
);
});
const { container, document } = await render(<Cmp />, { debug });
await trigger(container.element, 'button', 'click');
expect(document.querySelector('section')!.textContent).toBe('X0t1t2X3t4');
});

it('element placeholders, no text after the swapped items', async () => {
const Cmp = component$(() => {
const done = useSignal(false);
return (
<div>
<button onClick$={() => (done.value = true)} />
<section>
{done.value ? <b key="c0">X0</b> : <i key="c0" />}
<Fragment key="t1">t1</Fragment>
<Fragment key="t2">t2</Fragment>
{done.value ? <b key="c3">X3</b> : <i key="c3" />}
{done.value ? <b key="c4">X4</b> : <i key="c4" />}
</section>
</div>
);
});
const { container, document } = await render(<Cmp />, { debug });
await trigger(container.element, 'button', 'click');
expect(document.querySelector('section')!.textContent).toBe('X0t1t2X3X4');
});

it('component placeholders replaced by keyed fragments', async () => {
const Cmp = component$(() => {
const done = useSignal(false);
return (
<div>
<button onClick$={() => (done.value = true)} />
<section>
{done.value ? (
<Fragment key="c0">
<Chord key="c0">X0</Chord>
</Fragment>
) : (
<Placeholder key="c0" />
)}
<Fragment key="t1">t1</Fragment>
<Fragment key="t2">t2</Fragment>
{done.value ? (
<Fragment key="c3">
<Chord key="c3">X3</Chord>
</Fragment>
) : (
<Placeholder key="c3" />
)}
<Fragment key="t4">t4</Fragment>
</section>
</div>
);
});
const { container, document } = await render(<Cmp />, { debug });
await trigger(container.element, 'button', 'click');
expect(document.querySelector('section')!.textContent).toBe('X0t1t2X3t4');
});
});
});
Loading