Skip to content

Commit f2dbf54

Browse files
authored
Merge pull request #7672 from QwikDev/v2-reuse-props-instance
fix: reuse the same props instance when props are changing
2 parents 2aa49d9 + ebd99c6 commit f2dbf54

File tree

8 files changed

+178
-16
lines changed

8 files changed

+178
-16
lines changed

.changeset/cyan-walls-sing.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: reuse the same props instance when props are changing

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
directGetPropsProxyProp,
1010
isJSXNode,
1111
type Props,
12+
type PropsProxy,
1213
} from '../shared/jsx/jsx-runtime';
1314
import { Slot } from '../shared/jsx/slot.public';
1415
import type { JSXNodeInternal, JSXOutput } from '../shared/jsx/types/jsx-node';
@@ -103,6 +104,7 @@ import { getFileLocationFromJsx } from '../shared/utils/jsx-filename';
103104
import { EffectProperty } from '../reactive-primitives/types';
104105
import { SubscriptionData } from '../reactive-primitives/subscription-data';
105106
import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl';
107+
import { _CONST_PROPS, _VAR_PROPS } from '../internal';
106108

107109
export const vnode_diff = (
108110
container: ClientContainer,
@@ -1063,16 +1065,36 @@ export const vnode_diff = (
10631065
}
10641066

10651067
if (host) {
1066-
const vNodeProps = vnode_getProp<any>(host, ELEMENT_PROPS, container.$getObjectById$);
1067-
shouldRender = shouldRender || propsDiffer(jsxProps, vNodeProps);
1068+
let vNodeProps = vnode_getProp<any>(host, ELEMENT_PROPS, container.$getObjectById$);
1069+
const propsAreDifferent = propsDiffer(jsxProps, vNodeProps);
1070+
shouldRender = shouldRender || propsAreDifferent;
10681071
if (shouldRender) {
1072+
if (propsAreDifferent) {
1073+
if (vNodeProps) {
1074+
// Reuse the same props instance, qrls can use the current props instance
1075+
// as a capture ref, so we can't change it.
1076+
// We need to do this directly, because normally we would subscribe to the signals
1077+
// if any signal is there.
1078+
vNodeProps[_CONST_PROPS] = (jsxProps as PropsProxy)[_CONST_PROPS];
1079+
vNodeProps[_VAR_PROPS] = (jsxProps as PropsProxy)[_VAR_PROPS];
1080+
} else if (jsxProps) {
1081+
// If there is no props instance, create a new one.
1082+
// We can do this because we are not using the props instance for anything else.
1083+
vnode_setProp(host, ELEMENT_PROPS, jsxProps);
1084+
vNodeProps = jsxProps;
1085+
}
1086+
}
1087+
// Assign the new QRL instance to the host.
1088+
// Unfortunately it is created every time, something to fix in the optimizer.
1089+
vnode_setProp(host, OnRenderProp, componentQRL);
1090+
10691091
/**
10701092
* Mark host as not deleted. The host could have been marked as deleted if it there was a
10711093
* cleanup run. Now we found it and want to reuse it, so we need to mark it as not
10721094
* deleted.
10731095
*/
10741096
host[VNodeProps.flags] &= ~VNodeFlags.Deleted;
1075-
container.$scheduler$(ChoreType.COMPONENT, host, componentQRL, jsxProps);
1097+
container.$scheduler$(ChoreType.COMPONENT, host, componentQRL, vNodeProps);
10761098
}
10771099
}
10781100
descendContentToProject(jsxNode.children, host);

packages/qwik/src/core/shared/component-execution.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ export const executeComponent = (
9898
if (!isInlineComponent) {
9999
container.setHostProp(renderHost, ELEMENT_SEQ_IDX, null);
100100
container.setHostProp(renderHost, USE_ON_LOCAL_SEQ_IDX, null);
101-
container.setHostProp(renderHost, ELEMENT_PROPS, props);
102101
}
103102

104103
if (vnode_isVNode(renderHost)) {

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-
129129
import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl';
130130
import type { StoreHandler } from '../reactive-primitives/impl/store';
131131
import { SignalImpl } from '../reactive-primitives/impl/signal-impl';
132+
import { isQrl } from './qrl/qrl-utils';
132133

133134
// Turn this on to get debug output of what the scheduler is doing.
134135
const DEBUG: boolean = false;
@@ -587,7 +588,10 @@ export const createScheduler = (
587588
}
588589

589590
// If the host is the same (or missing), and the type is the same, we need to compare the target.
590-
if (a.$target$ !== b.$target$ || a.$payload$ !== b.$payload$) {
591+
if (a.$target$ !== b.$target$) {
592+
if (isQrl(a.$target$) && isQrl(b.$target$) && a.$target$.$hash$ === b.$target$.$hash$) {
593+
return 0;
594+
}
591595
// 1 means that we are going to process chores as FIFO
592596
return 1;
593597
}
@@ -643,7 +647,7 @@ export const createScheduler = (
643647
* multiple times during component execution. For this reason it is necessary for us to update
644648
* the chore with the latest result of the signal.
645649
*/
646-
if (existing.$type$ === ChoreType.NODE_DIFF) {
650+
if (existing.$payload$ !== value.$payload$) {
647651
existing.$payload$ = value.$payload$;
648652
}
649653
if (existing.$executed$) {

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

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ function Hola(props: any) {
3939
return <div {...props}></div>;
4040
}
4141

42+
const globalObj = ['foo', 'bar'];
43+
4244
describe.each([
4345
{ render: ssrRenderToDom }, //
4446
{ render: domRender }, //
@@ -1922,14 +1924,14 @@ describe.each([
19221924
});
19231925

19241926
const Cmp = component$(() => {
1925-
const $toggled = useSignal<boolean>(false);
1927+
const toggled = useSignal<boolean>(false);
19261928

19271929
return (
19281930
<TestB
1929-
aria-label={$toggled.value ? 'a' : 'a1'}
1930-
title={$toggled.value ? 'a' : 'a1'}
1931+
aria-label={toggled.value ? 'a' : 'a1'}
1932+
title={toggled.value ? 'a' : 'a1'}
19311933
onClick$={() => {
1932-
$toggled.value = !$toggled.value;
1934+
toggled.value = !toggled.value;
19331935
}}
19341936
>
19351937
<span>Hello, World!</span>
@@ -2255,6 +2257,80 @@ describe.each([
22552257
expect(vnode_getProp(vnode_getParent(h1Element)!, OnRenderProp, null)).toBeNull();
22562258
});
22572259

2260+
it('should reuse the same props instance when props are changing', async () => {
2261+
(globalThis as any).logs = [];
2262+
type ChildProps = {
2263+
obj: string;
2264+
foo: SignalType<number>;
2265+
};
2266+
const Child = component$<ChildProps>(({ obj, foo }) => {
2267+
(globalThis as any).logs.push('child render ' + obj);
2268+
useTask$(({ track }) => {
2269+
foo && track(foo);
2270+
(globalThis as any).logs.push(obj);
2271+
});
2272+
return <></>;
2273+
});
2274+
2275+
const Cmp = component$(() => {
2276+
const foo = useSignal(0);
2277+
(globalThis as any).logs.push('parent render');
2278+
return (
2279+
<div>
2280+
<button
2281+
onClick$={() => {
2282+
foo.value === 0 ? (foo.value = 1) : (foo.value = 0);
2283+
}}
2284+
>
2285+
click
2286+
</button>
2287+
<Child obj={globalObj[foo.value]} foo={foo} />
2288+
</div>
2289+
);
2290+
});
2291+
2292+
const { document } = await render(<Cmp />, { debug });
2293+
2294+
await trigger(document.body, 'button', 'click');
2295+
2296+
expect((globalThis as any).logs).toEqual([
2297+
'parent render',
2298+
'child render foo',
2299+
'foo',
2300+
'parent render',
2301+
'bar',
2302+
'child render bar',
2303+
]);
2304+
2305+
(globalThis as any).logs = undefined;
2306+
});
2307+
2308+
it('should change component props to new one for the same component with the same key', async () => {
2309+
(globalThis as any).logs = [];
2310+
const FirstCmp = component$((props: { foo?: string; bar?: string }) => {
2311+
(globalThis as any).logs.push('foo' in props, 'bar' in props);
2312+
return <div>{props.foo}</div>;
2313+
});
2314+
2315+
const Cmp = component$(() => {
2316+
const toggle = useSignal(true);
2317+
return (
2318+
<>
2319+
<button onClick$={() => (toggle.value = !toggle.value)}></button>
2320+
{toggle.value ? <FirstCmp key="1" foo="foo" /> : <FirstCmp key="1" bar="bar" />}
2321+
</>
2322+
);
2323+
});
2324+
2325+
const { document } = await render(<Cmp />, { debug });
2326+
expect((globalThis as any).logs).toEqual([true, false]);
2327+
await trigger(document.body, 'button', 'click');
2328+
expect((globalThis as any).logs).toEqual([true, false, false, true]);
2329+
await trigger(document.body, 'button', 'click');
2330+
expect((globalThis as any).logs).toEqual([true, false, false, true, true, false]);
2331+
(globalThis as any).logs = undefined;
2332+
});
2333+
22582334
describe('regression', () => {
22592335
it('#3643', async () => {
22602336
const Issue3643 = component$(() => {

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
type PropsOf,
1414
type QRL,
1515
} from "@qwik.dev/core";
16-
import { h, SSRComment, SSRRaw } from "@qwik.dev/core/internal";
16+
import { h, SSRComment, SSRRaw, type Signal } from "@qwik.dev/core/internal";
1717
import { delay } from "../streaming/demo";
1818

1919
export const Render = component$(() => {
@@ -103,6 +103,7 @@ export const RenderChildren = component$<{ v: number }>(({ v }) => {
103103
<Issue4455 />
104104
<Issue5266 />
105105
<DynamicButton id="dynamic-button" />;
106+
<RerenderOnce />
106107
</>
107108
);
108109
});
@@ -960,3 +961,32 @@ export const DynamicButton = component$<any>(
960961
);
961962
},
962963
);
964+
965+
const globalObj = ["foo", "bar"];
966+
967+
let logs: any[] = [];
968+
969+
const RerenderOnceChild = component$<{ obj: string; foo: Signal<number> }>(
970+
({ obj, foo }) => {
971+
logs.push("render Cmp", obj, foo.value);
972+
return <span id="rerender-once-child">{JSON.stringify(logs)}</span>;
973+
},
974+
);
975+
976+
export const RerenderOnce = component$(() => {
977+
const foo = useSignal(0);
978+
logs = [];
979+
return (
980+
<div>
981+
<button
982+
id="rerender-once-button"
983+
onClick$={() => {
984+
foo.value === 0 ? (foo.value = 1) : (foo.value = 0);
985+
}}
986+
>
987+
click
988+
</button>
989+
<RerenderOnceChild obj={globalObj[foo.value]} foo={foo} />
990+
</div>
991+
);
992+
});

starters/e2e/e2e.render.e2e.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ test.describe("render", () => {
1212
});
1313
});
1414

15-
function tests() {
15+
function tests(isClient: boolean) {
1616
test("should load", async ({ page }) => {
1717
const button = page.locator("button#increment");
1818
const text = page.locator("#rerenders");
@@ -486,9 +486,36 @@ test.describe("render", () => {
486486
await button.click();
487487
await expect(tag).toHaveAttribute("data-v", "bar");
488488
});
489+
490+
test("should rerender child once", async ({ page }) => {
491+
const button = page.locator("#rerender-once-button");
492+
expect(await page.locator("#rerender-once-child").innerHTML()).toEqual(
493+
'["render Cmp","foo",0]',
494+
);
495+
await button.click();
496+
if (isClient) {
497+
await expect(page.locator("#rerender-once-child")).toHaveText(
498+
'["render Cmp","foo",0,"render Cmp","bar",1]',
499+
);
500+
} else {
501+
await expect(page.locator("#rerender-once-child")).toHaveText(
502+
'["render Cmp","bar",1]',
503+
);
504+
}
505+
await button.click();
506+
if (isClient) {
507+
await expect(page.locator("#rerender-once-child")).toHaveText(
508+
'["render Cmp","foo",0,"render Cmp","bar",1,"render Cmp","foo",0]',
509+
);
510+
} else {
511+
await expect(page.locator("#rerender-once-child")).toHaveText(
512+
'["render Cmp","bar",1,"render Cmp","foo",0]',
513+
);
514+
}
515+
});
489516
}
490517

491-
tests();
518+
tests(false);
492519

493520
test.describe("client rerender", () => {
494521
test.beforeEach(async ({ page }) => {
@@ -499,7 +526,7 @@ test.describe("render", () => {
499526
`Render ${Number(v) + 1}`,
500527
);
501528
});
502-
tests();
529+
tests(true);
503530
});
504531

505532
test("pr3475", async ({ page }) => {

starters/e2e/e2e.signals.e2e.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,8 +450,7 @@ test.describe("signals", () => {
450450
await expect(result).toHaveAttribute("data-value", "collision");
451451
});
452452

453-
// This test is flaky, we have it working well in v2
454-
test.skip("issue 4228", async ({ page }) => {
453+
test("issue 4228", async ({ page }) => {
455454
const buttonA = page.locator("#issue-4228-button-a");
456455
const buttonB = page.locator("#issue-4228-button-b");
457456
const buttonC = page.locator("#issue-4228-button-c");

0 commit comments

Comments
 (0)