Skip to content

Commit 130129b

Browse files
authored
Merge branch 'main' into JoviDeCroock/reduce-bundle-size
2 parents 226b5b4 + d598771 commit 130129b

7 files changed

Lines changed: 212 additions & 8 deletions

File tree

compat/src/suspense.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { Component, Fragment, createElement, options } from 'preact';
22
import {
33
COMPONENT_FORCE,
44
FORCE_PROPS_REVALIDATE,
5-
MODE_HYDRATE
5+
MODE_HYDRATE,
6+
UNDEFINED
67
} from '../../src/constants';
78
import { assign } from './util';
89

@@ -49,7 +50,7 @@ function detachedClone(vnode, detachedParent, parentDom) {
4950
vnode._component.__hooks = null;
5051
}
5152

52-
vnode = assign({}, vnode);
53+
vnode = assign({ constructor: UNDEFINED }, vnode);
5354
if (vnode._component != null) {
5455
if (vnode._component._parentDom == parentDom) {
5556
vnode._component._parentDom = detachedParent;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { act, setupRerender } from 'preact/test-utils';
2+
import { createElement, render, Suspense } from 'preact/compat';
3+
import { setupScratch, teardown } from '../../../test/_util/helpers';
4+
import { createLazy } from './suspense-utils';
5+
import { expect } from 'vitest';
6+
7+
/** @jsx createElement */
8+
9+
// Suspense detaches the suspended tree by cloning its vnodes, which trips the
10+
// "override mistake" when `Object.prototype.constructor` is non-writable
11+
// (SES `lockdown()`, `node --frozen-intrinsics`, LavaMoat). See #5109 and
12+
// test/browser/hardened-js.test.jsx.
13+
describe('hardened JS (non-writable Object.prototype.constructor)', () => {
14+
let scratch, rerender, originalDescriptor;
15+
16+
beforeEach(() => {
17+
scratch = setupScratch();
18+
rerender = setupRerender();
19+
20+
originalDescriptor = Object.getOwnPropertyDescriptor(
21+
Object.prototype,
22+
'constructor'
23+
);
24+
Object.defineProperty(Object.prototype, 'constructor', {
25+
...originalDescriptor,
26+
writable: false
27+
});
28+
});
29+
30+
afterEach(() => {
31+
Object.defineProperty(Object.prototype, 'constructor', originalDescriptor);
32+
teardown(scratch);
33+
});
34+
35+
it('should suspend and resume when Object.prototype is hardened', () => {
36+
const [Lazy, resolve] = createLazy();
37+
38+
render(
39+
<Suspense fallback={<div>Suspended...</div>}>
40+
<Lazy />
41+
</Suspense>,
42+
scratch
43+
);
44+
45+
rerender();
46+
expect(scratch.innerHTML).to.equal('<div>Suspended...</div>');
47+
48+
return act(() => resolve(() => <div>Hello</div>)).then(() => {
49+
expect(scratch.innerHTML).to.equal('<div>Hello</div>');
50+
});
51+
});
52+
});

src/component.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import {
22
COMPONENT_DIRTY,
33
COMPONENT_FORCE,
44
MODE_HYDRATE,
5-
NULL
5+
NULL,
6+
UNDEFINED
67
} from './constants';
78
import { Fragment } from './create-element';
89
import { commitRoot, diff } from './diff/index';
@@ -135,7 +136,7 @@ function renderComponent(component) {
135136

136137
const parentDom = component._parentDom;
137138
if (parentDom) {
138-
const newVNode = assign({}, oldVNode);
139+
const newVNode = assign({ constructor: UNDEFINED }, oldVNode);
139140
newVNode._original = oldVNode._original + 1;
140141
if (options.vnode) options.vnode(newVNode);
141142

src/diff/index.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ function cloneNode(node) {
493493

494494
if (node.constructor !== UNDEFINED) return NULL;
495495

496-
return assign({}, node);
496+
return assign({ constructor: UNDEFINED }, node);
497497
}
498498

499499
/**
@@ -590,11 +590,17 @@ function diffElementNodes(
590590
dom.data = newProps;
591591
}
592592
} else {
593+
// A <template> holds its children in a separate document-fragment, both
594+
// when reading existing DOM and when inserting new nodes. `parentDom` is
595+
// unused from here on, so we reuse it as the container for our children.
596+
// @ts-expect-error
597+
parentDom = nodeType == 'template' ? dom.content : dom;
598+
593599
// If excessDomChildren was not null, repopulate it with the current element's children:
594600
excessDomChildren =
595601
nodeType == 'textarea' && newProps.defaultValue != NULL
596602
? NULL
597-
: excessDomChildren && slice.call(dom.childNodes);
603+
: excessDomChildren && slice.call(parentDom.childNodes);
598604

599605
// If we are in a situation where we are not hydrating but are using
600606
// existing DOM (e.g. replaceNode) we should read the existing DOM
@@ -664,8 +670,7 @@ function diffElementNodes(
664670
}
665671

666672
diffChildren(
667-
// @ts-expect-error
668-
nodeType == 'template' ? dom.content : dom,
673+
parentDom,
669674
isArray(newChildren) ? newChildren : [newChildren],
670675
newVNode,
671676
oldVNode,

test/browser/hardened-js.test.jsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createElement, render, Component, Fragment } from 'preact';
2+
import { setupRerender } from 'preact/test-utils';
3+
import { setupScratch, teardown } from '../_util/helpers';
4+
import { expect } from 'vitest';
5+
6+
/** @jsx createElement */
7+
/** @jsxFrag Fragment */
8+
9+
// Hardened JavaScript environments (SES `lockdown()`, `node
10+
// --frozen-intrinsics`, LavaMoat) freeze `Object.prototype`, which makes
11+
// `Object.prototype.constructor` non-writable. Copying a vnode — which carries
12+
// `constructor: undefined` as its JSON-injection guard — onto a bare `{}` then
13+
// hits the "override mistake" and throws. See #5109.
14+
//
15+
// Making `constructor` non-writable is the narrowest reproduction of that and,
16+
// unlike freezing, it is reversible so the rest of the suite is unaffected.
17+
describe('hardened JS (non-writable Object.prototype.constructor)', () => {
18+
let scratch, rerender, originalDescriptor;
19+
20+
beforeEach(() => {
21+
scratch = setupScratch();
22+
rerender = setupRerender();
23+
24+
originalDescriptor = Object.getOwnPropertyDescriptor(
25+
Object.prototype,
26+
'constructor'
27+
);
28+
Object.defineProperty(Object.prototype, 'constructor', {
29+
...originalDescriptor,
30+
writable: false
31+
});
32+
});
33+
34+
afterEach(() => {
35+
Object.defineProperty(Object.prototype, 'constructor', originalDescriptor);
36+
teardown(scratch);
37+
});
38+
39+
it('should re-render a component when Object.prototype is hardened', () => {
40+
class Counter extends Component {
41+
constructor(props) {
42+
super(props);
43+
this.state = { count: 0 };
44+
}
45+
46+
render() {
47+
return (
48+
<button
49+
onClick={() => this.setState({ count: this.state.count + 1 })}
50+
>
51+
{this.state.count}
52+
</button>
53+
);
54+
}
55+
}
56+
57+
render(<Counter />, scratch);
58+
expect(scratch.innerHTML).to.equal('<button>0</button>');
59+
60+
scratch.firstChild.click();
61+
rerender();
62+
expect(scratch.innerHTML).to.equal('<button>1</button>');
63+
});
64+
65+
it('should render a component returning a Fragment when Object.prototype is hardened', () => {
66+
// keyless Fragment results get cloned through `cloneNode`
67+
const App = () => (
68+
<>
69+
<span>a</span>
70+
<span>b</span>
71+
</>
72+
);
73+
74+
render(<App />, scratch);
75+
expect(scratch.innerHTML).to.equal('<span>a</span><span>b</span>');
76+
});
77+
});

test/browser/hydrate.test.jsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,4 +589,24 @@ describe('hydrate()', () => {
589589
expect(caught).to.equal(error);
590590
expect(caught.message).to.equal('real error');
591591
});
592+
593+
it('should hydrate <template> tags', () => {
594+
function App() {
595+
return (
596+
<template>
597+
<h1>it works</h1>
598+
</template>
599+
);
600+
}
601+
602+
scratch.innerHTML = '<template><h1>it works</h1></template>';
603+
const h1 = scratch.firstChild.content.firstChild;
604+
605+
hydrate(<App />, scratch);
606+
607+
expect(scratch.innerHTML).to.equal(
608+
'<template><h1>it works</h1></template>'
609+
);
610+
expect(scratch.firstChild.content.firstChild).to.equal(h1);
611+
});
592612
});

test/browser/render.test.jsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,54 @@ describe('render()', () => {
103103
expect(clone.firstChild.outerHTML).to.eql('<h1>it works</h1>');
104104
});
105105

106+
it('should reuse existing <template> content', () => {
107+
function App() {
108+
return (
109+
<template>
110+
<h1>it works</h1>
111+
</template>
112+
);
113+
}
114+
115+
scratch.innerHTML = `<template><h1>it works</h1></template>`;
116+
const template = scratch.firstChild;
117+
const h1 = template.content.firstChild;
118+
119+
render(<App />, scratch);
120+
121+
expect(scratch.firstChild).to.equal(template);
122+
expect(template.content.childNodes).to.have.length(1);
123+
expect(template.content.firstChild).to.equal(h1);
124+
});
125+
126+
it('should update the children of a <template>', () => {
127+
function App({ items }) {
128+
return (
129+
<div>
130+
<template>
131+
{items.map(i => (
132+
<h1 key={i}>{i}</h1>
133+
))}
134+
</template>
135+
<p>after</p>
136+
</div>
137+
);
138+
}
139+
140+
render(<App items={[1, 2]} />, scratch);
141+
let template = scratch.firstChild.firstChild;
142+
expect(template.content.textContent).to.equal('12');
143+
144+
render(<App items={[1, 2, 3]} />, scratch);
145+
expect(template.content.textContent).to.equal('123');
146+
147+
render(<App items={[3]} />, scratch);
148+
expect(template.content.textContent).to.equal('3');
149+
expect(scratch.innerHTML).to.equal(
150+
'<div><template><h1>3</h1></template><p>after</p></div>'
151+
);
152+
});
153+
106154
it('should render function components with an undefined prototype', () => {
107155
const Foo = () => <div>foo</div>;
108156
Foo.prototype = undefined;

0 commit comments

Comments
 (0)