Skip to content

Commit 7f2466b

Browse files
committed
fix(ssr): make child props immutable
1 parent fe4e95f commit 7f2466b

File tree

10 files changed

+117
-2
lines changed

10 files changed

+117
-2
lines changed

packages/@lwc/engine-server/src/__tests__/fixtures/parent-child-read-only-pseudo-attr/error.txt

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<x-parent>
2+
<template shadowrootmode="open">
3+
<x-child>
4+
<template shadowrootmode="open">
5+
<div>
6+
7+
array(disabled): error hit during mutation
8+
object(title): error hit during mutation
9+
deep(spellcheck): error hit during mutation
10+
object(title): error hit during deletion
11+
</div>
12+
</template>
13+
</x-child>
14+
</template>
15+
</x-parent>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const tagName = 'x-parent';
2+
export { default } from 'x/parent';
3+
export * from 'x/parent';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<div>{result}</div>
3+
</template>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { LightningElement, api } from "lwc";
2+
3+
export default class extends LightningElement {
4+
// Intentionally using the HTML global attribute names disabled/title/spellcheck here
5+
@api disabled // array
6+
@api title // object
7+
@api spellcheck // deep
8+
9+
result
10+
11+
connectedCallback() {
12+
const results = []
13+
14+
try {
15+
this.disabled.push('bar')
16+
} catch (err) {
17+
results.push('array(disabled): error hit during mutation')
18+
}
19+
20+
try {
21+
this.title.foo = 'baz'
22+
} catch (err) {
23+
results.push('object(title): error hit during mutation')
24+
}
25+
26+
try {
27+
this.spellcheck.foo[0].quux = 'quux'
28+
} catch (err) {
29+
results.push('deep(spellcheck): error hit during mutation')
30+
}
31+
32+
try {
33+
delete this.title.foo
34+
} catch (err) {
35+
results.push('object(title): error hit during deletion')
36+
}
37+
38+
this.result = '\n' + results.join('\n')
39+
}
40+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<template>
2+
<x-child
3+
disabled={array}
4+
title={object}
5+
spellcheck={deep}
6+
>
7+
</x-child>
8+
</template>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { LightningElement } from "lwc";
2+
3+
export default class extends LightningElement {
4+
array = [1, 2, 3]
5+
object = { foo: 'bar '}
6+
deep = { foo: [{ bar: 'baz' }]}
7+
}

packages/@lwc/ssr-compiler/src/compile-template/transformers/component.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { TransformerContext } from '../types';
1515
import { expressionIrToEs } from '../expression';
1616
import { irChildrenToEs, irToEs } from '../ir-to-es';
1717
import { isNullableOf } from '../../estree/validators';
18+
import { bImportDeclaration } from '../../estree/builders';
1819
import type { CallExpression as EsCallExpression, Expression as EsExpression } from 'estree';
1920

2021
import type {
@@ -30,8 +31,8 @@ import type { Transformer } from '../types';
3031

3132
const bYieldFromChildGenerator = esTemplateWithYield`
3233
{
33-
const childProps = ${is.objectExpression};
34-
const childAttrs = ${is.objectExpression};
34+
const childProps = __cloneAndDeepFreeze(${/* child props */ is.objectExpression});
35+
const childAttrs = ${/* child attrs */ is.objectExpression};
3536
const slottedContent = {
3637
light: Object.create(null),
3738
shadow: async function* () {
@@ -101,6 +102,10 @@ export const Component: Transformer<IrComponent> = function Component(node, cxt)
101102
const importPath = kebabcaseToCamelcase(node.name);
102103
const componentImport = bImportGenerateMarkup(childGeneratorLocalName, importPath);
103104
cxt.hoist(componentImport, childGeneratorLocalName);
105+
cxt.hoist(
106+
bImportDeclaration([{ cloneAndDeepFreeze: '__cloneAndDeepFreeze' }]),
107+
'import:cloneAndDeepFreeze'
108+
);
104109
const childTagName = node.name;
105110

106111
// Anything inside the slotted content is a normal slotted content except for `<template lwc:slot-data>` which is a scoped slot.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (c) 2024, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: MIT
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
6+
*/
7+
8+
import { freeze, entries, isObject, isArray, create, isNull } from '@lwc/shared';
9+
10+
// Deep freeze and clone an object. Designed for cloning/freezing child props when passed from a parent to a child so
11+
// that they are immutable. This is one of the normal guarantees of both engine-dom and engine-server that we want to
12+
// emulate in ssr-runtime. The goal here is that a child cannot mutate the props of its parent and thus affect
13+
// the parent's rendering, which would lead to bidirectional reactivity and mischief.
14+
export function cloneAndDeepFreeze(obj: any): any {
15+
if (isArray(obj)) {
16+
const res = [];
17+
for (let i = 0; i < obj.length; i++) {
18+
res[i] = cloneAndDeepFreeze(obj[i]);
19+
}
20+
freeze(res);
21+
return res;
22+
} else if (!isNull(obj) && isObject(obj)) {
23+
const res = create(null);
24+
for (const [key, value] of entries(obj as any)) {
25+
(res as any)[key] = cloneAndDeepFreeze(value);
26+
}
27+
freeze(res);
28+
return res;
29+
} else {
30+
// primitive
31+
return obj;
32+
}
33+
}

packages/@lwc/ssr-runtime/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ export {
3131
export { hasScopedStaticStylesheets, renderStylesheets } from './styles';
3232
export { toIteratorDirective } from './to-iterator-directive';
3333
export { validateStyleTextContents } from './validate-style-text-contents';
34+
export { cloneAndDeepFreeze } from './clone-and-deep-freeze';

0 commit comments

Comments
 (0)