Skip to content

Commit b390f28

Browse files
authored
fix(core): bound lastSeen vnode tracking (#630)
1 parent 0522ae2 commit b390f28

7 files changed

Lines changed: 113 additions & 9 deletions

File tree

.changeset/tidy-dogs-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@prefresh/core': patch
3+
---
4+
5+
Prevent stale component vnodes from being retained across rerenders and unmounts

packages/core/src/runtime/unmount.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { options } from 'preact';
2-
import { vnodesForComponent } from './vnodesForComponent';
2+
import { vnodesForComponent, clearLastSeen } from './vnodesForComponent';
33

44
const oldUnmount = options.unmount;
55
options.unmount = vnode => {
66
const type = (vnode || {}).type;
7-
if (typeof type === 'function' && vnodesForComponent.has(type)) {
8-
const vnodes = vnodesForComponent.get(type);
9-
if (vnodes) {
10-
const index = vnodes.indexOf(vnode);
11-
if (index !== -1) {
12-
vnodes.splice(index, 1);
7+
if (typeof type === 'function') {
8+
clearLastSeen(vnode);
9+
10+
if (vnodesForComponent.has(type)) {
11+
const vnodes = vnodesForComponent.get(type);
12+
if (vnodes) {
13+
const index = vnodes.indexOf(vnode);
14+
if (index !== -1) {
15+
vnodes.splice(index, 1);
16+
}
1317
}
1418
}
1519
}

packages/core/src/runtime/vnode.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { options } from 'preact';
22
import {
33
vnodesForComponent,
44
mappedVNodes,
5-
lastSeen,
5+
setLastSeen,
66
} from './vnodesForComponent';
77
import { VNODE_COMPONENT } from '../constants';
88

@@ -57,7 +57,7 @@ const oldDiffed = options.diffed;
5757
options.diffed = vnode => {
5858
if (vnode && typeof vnode.type === 'function') {
5959
const vnodes = vnodesForComponent.get(vnode.type);
60-
lastSeen.set(vnode.__v, vnode);
60+
setLastSeen(vnode);
6161
if (vnodes) {
6262
const matchingDom = vnodes.filter(p => p.__c === vnode.__c);
6363
if (matchingDom.length > 1) {

packages/core/src/runtime/vnodesForComponent.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,27 @@
22
export const vnodesForComponent = new WeakMap();
33
export const mappedVNodes = new WeakMap();
44
export const lastSeen = new Map();
5+
6+
const lastSeenByInstance = new WeakMap();
7+
8+
export const setLastSeen = vnode => {
9+
const component = vnode.__c;
10+
if (component) {
11+
if (lastSeenByInstance.has(component)) {
12+
lastSeen.delete(lastSeenByInstance.get(component));
13+
}
14+
lastSeenByInstance.set(component, vnode.__v);
15+
}
16+
17+
lastSeen.set(vnode.__v, vnode);
18+
};
19+
20+
export const clearLastSeen = vnode => {
21+
lastSeen.delete(vnode.__v);
22+
23+
const component = vnode.__c;
24+
if (component && lastSeenByInstance.has(component)) {
25+
lastSeen.delete(lastSeenByInstance.get(component));
26+
lastSeenByInstance.delete(component);
27+
}
28+
};

test/fixture/vite-babel/src/app.jsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { h } from 'preact';
2+
import { useState } from 'preact/hooks';
23
import { useCounter } from './useCounter';
34
import { StoreProvider } from './context';
45
import { Products } from './products';
@@ -10,6 +11,9 @@ import { Style } from './styles';
1011

1112
setup(h);
1213

14+
const lastSeenPayloads = [];
15+
self.__lastSeenPayloads = lastSeenPayloads;
16+
1317
function Test() {
1418
const [count, increment] = useCounter();
1519
return (
@@ -20,10 +24,30 @@ function Test() {
2024
)
2125
}
2226

27+
function LastSeenChild({ payload }) {
28+
return <span className="last-seen-child">{payload.count}</span>;
29+
}
30+
31+
function LastSeenTest() {
32+
const [count, setCount] = useState(0);
33+
const payload = { count };
34+
lastSeenPayloads.push(new WeakRef(payload));
35+
36+
return (
37+
<div>
38+
<button className="last-seen-increment" onClick={() => setCount(count + 1)}>
39+
Increment tracked component
40+
</button>
41+
<LastSeenChild payload={payload} />
42+
</div>
43+
);
44+
}
45+
2346
export function App(props) {
2447
return (
2548
<Style id="color">
2649
<Test />
50+
<LastSeenTest />
2751
<Greeting />
2852
<StoreProvider>
2953
<Products />

test/fixture/vite/src/app.jsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { h } from 'preact';
2+
import { useState } from 'preact/hooks';
23
import { useCounter } from './useCounter';
34
import { StoreProvider } from './context';
45
import { Products } from './products';
@@ -10,6 +11,9 @@ import { Style } from './styles';
1011

1112
setup(h);
1213

14+
const lastSeenPayloads = [];
15+
self.__lastSeenPayloads = lastSeenPayloads;
16+
1317
function Test() {
1418
const [count, increment] = useCounter();
1519
return (
@@ -20,10 +24,30 @@ function Test() {
2024
)
2125
}
2226

27+
function LastSeenChild({ payload }) {
28+
return <span className="last-seen-child">{payload.count}</span>;
29+
}
30+
31+
function LastSeenTest() {
32+
const [count, setCount] = useState(0);
33+
const payload = { count };
34+
lastSeenPayloads.push(new WeakRef(payload));
35+
36+
return (
37+
<div>
38+
<button className="last-seen-increment" onClick={() => setCount(count + 1)}>
39+
Increment tracked component
40+
</button>
41+
<LastSeenChild payload={payload} />
42+
</div>
43+
);
44+
}
45+
2346
export function App(props) {
2447
return (
2548
<Style id="color">
2649
<Test />
50+
<LastSeenTest />
2751
<Greeting />
2852
<StoreProvider>
2953
<Products />

test/index.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,29 @@ describe('Prefresh integrations', () => {
127127
await expectByPolling(() => getText(button), 'Increment (+)');
128128
});
129129

130+
test('does not retain stale vnodes after rerenders', async () => {
131+
const increment = await page.$('.last-seen-increment');
132+
133+
await page.evaluate(() => {
134+
self.__lastSeenPayloads.length = 0;
135+
});
136+
137+
for (let i = 0; i < 20; i++) {
138+
await increment.click();
139+
}
140+
141+
await expectByPolling(() => getText('.last-seen-child'), '20');
142+
143+
const client = await page.target().createCDPSession();
144+
await client.send('HeapProfiler.collectGarbage');
145+
expect(
146+
await page.evaluate(
147+
() => self.__lastSeenPayloads.filter(ref => ref.deref()).length
148+
)
149+
).toBe(1);
150+
await client.detach();
151+
});
152+
130153
test('add export', async () => {
131154
const button = await page.$('.button');
132155
await expectByPolling(() => getText(button), 'Increment (+)');

0 commit comments

Comments
 (0)