Skip to content

Commit

Permalink
fix(ssr): make child props immutable (#4779)
Browse files Browse the repository at this point in the history
Co-authored-by: Will Harney <62956339+wjhsf@users.noreply.github.com>
  • Loading branch information
nolanlawson and wjhsf authored Nov 7, 2024
1 parent 752e73f commit a85b192
Show file tree
Hide file tree
Showing 10 changed files with 117 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<x-parent>
<template shadowrootmode="open">
<x-child>
<template shadowrootmode="open">
<div>

array(disabled): error hit during mutation
object(title): error hit during mutation
deep(spellcheck): error hit during mutation
object(title): error hit during deletion
</div>
</template>
</x-child>
</template>
</x-parent>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const tagName = 'x-parent';
export { default } from 'x/parent';
export * from 'x/parent';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>{result}</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { LightningElement, api } from "lwc";

export default class extends LightningElement {
// Intentionally using the HTML global attribute names disabled/title/spellcheck here
@api disabled // array
@api title // object
@api spellcheck // deep

result

connectedCallback() {
const results = []

try {
this.disabled.push('bar')
} catch (err) {
results.push('array(disabled): error hit during mutation')
}

try {
this.title.foo = 'baz'
} catch (err) {
results.push('object(title): error hit during mutation')
}

try {
this.spellcheck.foo[0].quux = 'quux'
} catch (err) {
results.push('deep(spellcheck): error hit during mutation')
}

try {
delete this.title.foo
} catch (err) {
results.push('object(title): error hit during deletion')
}

this.result = '\n' + results.join('\n')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>
<x-child
disabled={array}
title={object}
spellcheck={deep}
>
</x-child>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { LightningElement } from "lwc";

export default class extends LightningElement {
array = [1, 2, 3]
object = { foo: 'bar '}
deep = { foo: [{ bar: 'baz' }]}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { TransformerContext } from '../types';
import { expressionIrToEs } from '../expression';
import { irChildrenToEs, irToEs } from '../ir-to-es';
import { isNullableOf } from '../../estree/validators';
import { bImportDeclaration } from '../../estree/builders';
import type { CallExpression as EsCallExpression, Expression as EsExpression } from 'estree';

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

const bYieldFromChildGenerator = esTemplateWithYield`
{
const childProps = ${is.objectExpression};
const childAttrs = ${is.objectExpression};
const childProps = __cloneAndDeepFreeze(${/* child props */ is.objectExpression});
const childAttrs = ${/* child attrs */ is.objectExpression};
const slottedContent = {
light: Object.create(null),
shadow: async function* () {
Expand Down Expand Up @@ -101,6 +102,10 @@ export const Component: Transformer<IrComponent> = function Component(node, cxt)
const importPath = kebabcaseToCamelcase(node.name);
const componentImport = bImportGenerateMarkup(childGeneratorLocalName, importPath);
cxt.hoist(componentImport, childGeneratorLocalName);
cxt.hoist(
bImportDeclaration([{ cloneAndDeepFreeze: '__cloneAndDeepFreeze' }]),
'import:cloneAndDeepFreeze'
);
const childTagName = node.name;

// Anything inside the slotted content is a normal slotted content except for `<template lwc:slot-data>` which is a scoped slot.
Expand Down
33 changes: 33 additions & 0 deletions packages/@lwc/ssr-runtime/src/clone-and-deep-freeze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2024, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/

import { freeze, entries, isObject, isArray, create, isNull, ArrayPush } from '@lwc/shared';

// Deep freeze and clone an object. Designed for cloning/freezing child props when passed from a parent to a child so
// that they are immutable. This is one of the normal guarantees of both engine-dom and engine-server that we want to
// emulate in ssr-runtime. The goal here is that a child cannot mutate the props of its parent and thus affect
// the parent's rendering, which would lead to bidirectional reactivity and mischief.
export function cloneAndDeepFreeze<T>(obj: T): T {
if (isArray(obj)) {
const res: any[] = [];
for (const item of obj) {
ArrayPush.call(res, cloneAndDeepFreeze(item));
}
freeze(res);
return res as T;
} else if (isObject(obj) && !isNull(obj)) {
const res = create(null);
for (const [key, value] of entries(obj)) {
(res as any)[key] = cloneAndDeepFreeze(value);
}
freeze(res);
return res;
} else {
// primitive
return obj;
}
}
1 change: 1 addition & 0 deletions packages/@lwc/ssr-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export {
export { hasScopedStaticStylesheets, renderStylesheets } from './styles';
export { toIteratorDirective } from './to-iterator-directive';
export { validateStyleTextContents } from './validate-style-text-contents';
export { cloneAndDeepFreeze } from './clone-and-deep-freeze';

0 comments on commit a85b192

Please sign in to comment.