Skip to content

Commit

Permalink
fix: setting ref to null after updating it with new element (#4054)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoviDeCroock committed Jun 30, 2023
1 parent a5c810c commit 76d6909
Show file tree
Hide file tree
Showing 8 changed files with 376 additions and 29 deletions.
79 changes: 79 additions & 0 deletions hooks/test/browser/combinations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,4 +398,83 @@ describe('combinations', () => {
});
expect(scratch.textContent).to.equal('2');
});

it.skip('parent and child refs should be set before all effects', () => {
const anchorId = 'anchor';
const tooltipId = 'tooltip';
const effectLog = [];

let useRef2 = sinon.spy(init => {
const realRef = useRef(init);
const ref = useRef(init);
Object.defineProperty(ref, 'current', {
get: () => realRef.current,
set: value => {
realRef.current = value;
effectLog.push('set ref ' + value?.tagName);
}
});
return ref;
});

function Tooltip({ anchorRef, children }) {
// For example, used to manually position the tooltip
const tooltipRef = useRef2(null);

useLayoutEffect(() => {
expect(anchorRef.current?.id).to.equal(anchorId);
expect(tooltipRef.current?.id).to.equal(tooltipId);
effectLog.push('tooltip layout effect');
}, [anchorRef, tooltipRef]);
useEffect(() => {
expect(anchorRef.current?.id).to.equal(anchorId);
expect(tooltipRef.current?.id).to.equal(tooltipId);
effectLog.push('tooltip effect');
}, [anchorRef, tooltipRef]);

return (
<div class="tooltip-wrapper">
<div id={tooltipId} ref={tooltipRef}>
{children}
</div>
</div>
);
}

function App() {
// For example, used to define what element to anchor the tooltip to
const anchorRef = useRef2(null);

useLayoutEffect(() => {
expect(anchorRef.current?.id).to.equal(anchorId);
effectLog.push('anchor layout effect');
}, [anchorRef]);
useEffect(() => {
expect(anchorRef.current?.id).to.equal(anchorId);
effectLog.push('anchor effect');
}, [anchorRef]);

return (
<div>
<p id={anchorId} ref={anchorRef}>
More info
</p>
<Tooltip anchorRef={anchorRef}>a tooltip</Tooltip>
</div>
);
}

act(() => {
render(<App />, scratch);
});

expect(effectLog).to.deep.equal([
'set ref P',
'set ref DIV',
'tooltip layout effect',
'anchor layout effect',
'tooltip effect',
'anchor effect'
]);
});
});
47 changes: 46 additions & 1 deletion hooks/test/browser/errorBoundary.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement, render } from 'preact';
import { setupScratch, teardown } from '../../../test/_util/helpers';
import { useErrorBoundary } from 'preact/hooks';
import { useErrorBoundary, useLayoutEffect } from 'preact/hooks';
import { setupRerender } from 'preact/test-utils';

/** @jsx createElement */
Expand Down Expand Up @@ -107,4 +107,49 @@ describe('errorBoundary', () => {
expect(spy2).to.be.calledWith(error);
expect(scratch.innerHTML).to.equal('<p>Error</p>');
});

it('does not invoke old effects when a cleanup callback throws an error and is handled', () => {
let throwErr = false;
let thrower = sinon.spy(() => {
if (throwErr) {
throw new Error('test');
}
});
let badEffect = sinon.spy(() => thrower);
let goodEffect = sinon.spy();

function EffectThrowsError() {
useLayoutEffect(badEffect);
return <span>Test</span>;
}

function Child({ children }) {
useLayoutEffect(goodEffect);
return children;
}

function App() {
const [err] = useErrorBoundary();
return err ? (
<p>Error</p>
) : (
<Child>
<EffectThrowsError />
</Child>
);
}

render(<App />, scratch);
expect(scratch.innerHTML).to.equal('<span>Test</span>');
expect(badEffect).to.be.calledOnce;
expect(goodEffect).to.be.calledOnce;

throwErr = true;
render(<App />, scratch);
rerender();
expect(scratch.innerHTML).to.equal('<p>Error</p>');
expect(thrower).to.be.calledOnce;
expect(badEffect).to.be.calledOnce;
expect(goodEffect).to.be.calledOnce;
});
});
37 changes: 37 additions & 0 deletions hooks/test/browser/useLayoutEffect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -476,4 +476,41 @@ describe('useLayoutEffect', () => {
expect(calls.length).to.equal(1);
expect(calls).to.deep.equal(['doing effecthi']);
});

it('should run layout affects after all refs are invoked', () => {
const calls = [];
const verifyRef = name => el => {
calls.push(name);
expect(document.body.contains(el), name).to.equal(true);
};

const App = () => {
const ref = useRef();
useLayoutEffect(() => {
expect(ref.current).to.equalNode(scratch.querySelector('p'));

calls.push('doing effect');
return () => {
calls.push('cleaning up');
};
});

return (
<div ref={verifyRef('callback ref outer')}>
<p ref={ref}>
<span ref={verifyRef('callback ref inner')}>Hi</span>
</p>
</div>
);
};

act(() => {
render(<App />, scratch);
});
expect(calls).to.deep.equal([
'callback ref inner',
'callback ref outer',
'doing effect'
]);
});
});
9 changes: 6 additions & 3 deletions src/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ function renderComponent(component) {
parentDom = component._parentDom;

if (parentDom) {
let commitQueue = [];
let commitQueue = [],
refQueue = [];
const oldVNode = assign({}, vnode);
oldVNode._original = vnode._original + 1;

Expand All @@ -138,9 +139,11 @@ function renderComponent(component) {
vnode._hydrating != null ? [oldDom] : null,
commitQueue,
oldDom == null ? getDomSibling(vnode) : oldDom,
vnode._hydrating
vnode._hydrating,
refQueue
);
commitRoot(commitQueue, vnode);

commitRoot(commitQueue, vnode, refQueue);

if (vnode._dom != oldDom) {
updateParentDomPointers(vnode);
Expand Down
22 changes: 9 additions & 13 deletions src/diff/children.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { isArray } from '../util';
* render (except when hydrating). Can be a sibling DOM element when diffing
* Fragments that have siblings. In most cases, it starts out as `oldChildren[0]._dom`.
* @param {boolean} isHydrating Whether or not we are in hydration
* @param {Array<any>} refQueue an array of elements needed to invoke refs
*/
export function diffChildren(
parentDom,
Expand All @@ -33,15 +34,15 @@ export function diffChildren(
excessDomChildren,
commitQueue,
oldDom,
isHydrating
isHydrating,
refQueue
) {
let i,
j,
oldVNode,
childVNode,
newDom,
firstChildDom,
refs,
skew = 0;

// This is a compression of oldParentVNode!=null && oldParentVNode != EMPTY_OBJ && oldParentVNode._children || EMPTY_ARR
Expand Down Expand Up @@ -138,15 +139,17 @@ export function diffChildren(
excessDomChildren,
commitQueue,
oldDom,
isHydrating
isHydrating,
refQueue
);

newDom = childVNode._dom;

if ((j = childVNode.ref) && oldVNode.ref != j) {
if (!refs) refs = [];
if (oldVNode.ref) refs.push(oldVNode.ref, null, childVNode);
refs.push(j, childVNode._component || newDom, childVNode);
if (oldVNode.ref) {
applyRef(oldVNode.ref, null, childVNode);
}
refQueue.push(j, childVNode._component || newDom, childVNode);
}

if (newDom != null) {
Expand Down Expand Up @@ -243,13 +246,6 @@ export function diffChildren(
unmount(oldChildren[i], oldChildren[i]);
}
}

// Set refs only after unmount
if (refs) {
for (i = 0; i < refs.length; i++) {
applyRef(refs[i], refs[++i], refs[++i]);
}
}
}

function reorderChildren(childVNode, oldDom, parentDom) {
Expand Down
25 changes: 18 additions & 7 deletions src/diff/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import options from '../options';
* element any new dom elements should be placed around. Likely `null` on first
* render (except when hydrating). Can be a sibling DOM element when diffing
* Fragments that have siblings. In most cases, it starts out as `oldChildren[0]._dom`.
* @param {boolean} [isHydrating] Whether or not we are in hydration
* @param {boolean} isHydrating Whether or not we are in hydration
* @param {Array<any>} refQueue an array of elements needed to invoke refs
*/
export function diff(
parentDom,
Expand All @@ -31,7 +32,8 @@ export function diff(
excessDomChildren,
commitQueue,
oldDom,
isHydrating
isHydrating,
refQueue
) {
let tmp,
newType = newVNode.type;
Expand Down Expand Up @@ -239,7 +241,8 @@ export function diff(
excessDomChildren,
commitQueue,
oldDom,
isHydrating
isHydrating,
refQueue
);

c.base = newVNode._dom;
Expand Down Expand Up @@ -269,7 +272,8 @@ export function diff(
isSvg,
excessDomChildren,
commitQueue,
isHydrating
isHydrating,
refQueue
);
}

Expand All @@ -293,7 +297,11 @@ export function diff(
* which have callbacks to invoke in commitRoot
* @param {import('../internal').VNode} root
*/
export function commitRoot(commitQueue, root) {
export function commitRoot(commitQueue, root, refQueue) {
for (let i = 0; i < refQueue.length; i++) {
applyRef(refQueue[i], refQueue[++i], refQueue[++i]);
}

if (options._commit) options._commit(root, commitQueue);

commitQueue.some(c => {
Expand Down Expand Up @@ -323,6 +331,7 @@ export function commitRoot(commitQueue, root) {
* @param {Array<import('../internal').Component>} commitQueue List of components
* which have callbacks to invoke in commitRoot
* @param {boolean} isHydrating Whether or not we are in hydration
* @param {Array<any>} refQueue an array of elements needed to invoke refs
* @returns {import('../internal').PreactElement}
*/
function diffElementNodes(
Expand All @@ -333,7 +342,8 @@ function diffElementNodes(
isSvg,
excessDomChildren,
commitQueue,
isHydrating
isHydrating,
refQueue
) {
let oldProps = oldVNode.props;
let newProps = newVNode.props;
Expand Down Expand Up @@ -445,7 +455,8 @@ function diffElementNodes(
excessDomChildren
? excessDomChildren[0]
: oldVNode._children && getDomSibling(oldVNode, 0),
isHydrating
isHydrating,
refQueue
);

// Remove children that are not part of any vnode.
Expand Down
8 changes: 5 additions & 3 deletions src/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export function render(vnode, parentDom, replaceNode) {
createElement(Fragment, null, [vnode]);

// List of effects that need to be called after diffing.
let commitQueue = [];
let commitQueue = [],
refQueue = [];
diff(
parentDom,
// Determine the new vnode tree and store it on the DOM element on
Expand All @@ -55,11 +56,12 @@ export function render(vnode, parentDom, replaceNode) {
: oldVNode
? oldVNode._dom
: parentDom.firstChild,
isHydrating
isHydrating,
refQueue
);

// Flush all queued effects
commitRoot(commitQueue, vnode);
commitRoot(commitQueue, vnode, refQueue);
}

/**
Expand Down
Loading

0 comments on commit 76d6909

Please sign in to comment.