Skip to content

Commit 5af18fd

Browse files
authored
feat: add freezeTemplate() API, warn on mutation (#2825)
* feat: add freezeTemplate() API, warn on mutation * fix: warn on slots/renderMode as well * fix: add comment * fix: remove duplicate process.env.NODE_ENV check
1 parent ac11672 commit 5af18fd

File tree

15 files changed

+392
-32
lines changed

15 files changed

+392
-32
lines changed

packages/@lwc/compiler/src/transformers/template.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ function serialize(
7070
`${namespace}-${name}_${path.basename(filename, path.extname(filename))}`
7171
);
7272
let buffer = '';
73+
buffer += `import { freezeTemplate } from "lwc";\n\n`;
7374
buffer += `import _implicitStylesheets from "${cssRelPath}";\n\n`;
7475
buffer += `import _implicitScopedStylesheets from "${scopedCssRelPath}?scoped=true";\n\n`;
7576
buffer += code;
@@ -83,6 +84,9 @@ function serialize(
8384
buffer += 'if (_implicitStylesheets || _implicitScopedStylesheets) {\n';
8485
buffer += ` tmpl.stylesheetToken = "${scopeToken}"\n`;
8586
buffer += '}\n';
87+
// Note that `renderMode` and `slots` are already rendered in @lwc/template-compiler and appear
88+
// as `code` above. At this point, no more expando props should be added to `tmpl`.
89+
buffer += 'freezeTemplate(tmpl);\n';
8690

8791
return buffer;
8892
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright (c) 2018, salesforce.com, 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+
import {
8+
ArrayPush,
9+
ArraySplice,
10+
ArrayUnshift,
11+
ArrayCopyWithin,
12+
ArrayFill,
13+
ArrayPop,
14+
ArrayShift,
15+
ArraySort,
16+
ArrayReverse,
17+
defineProperty,
18+
getOwnPropertyDescriptor,
19+
isUndefined,
20+
} from '@lwc/shared';
21+
import { logError } from '../shared/logger';
22+
import { Template } from './template';
23+
import { TemplateStylesheetFactories } from './stylesheet';
24+
25+
// See @lwc/engine-core/src/framework/template.ts
26+
const TEMPLATE_PROPS = ['slots', 'stylesheetToken', 'stylesheets', 'renderMode'] as const;
27+
28+
// Via https://www.npmjs.com/package/object-observer
29+
const ARRAY_MUTATION_METHODS = [
30+
'pop',
31+
'push',
32+
'shift',
33+
'unshift',
34+
'reverse',
35+
'sort',
36+
'fill',
37+
'splice',
38+
'copyWithin',
39+
] as const;
40+
41+
function getOriginalArrayMethod(prop: typeof ARRAY_MUTATION_METHODS[number]) {
42+
switch (prop) {
43+
case 'pop':
44+
return ArrayPop;
45+
case 'push':
46+
return ArrayPush;
47+
case 'shift':
48+
return ArrayShift;
49+
case 'unshift':
50+
return ArrayUnshift;
51+
case 'reverse':
52+
return ArrayReverse;
53+
case 'sort':
54+
return ArraySort;
55+
case 'fill':
56+
return ArrayFill;
57+
case 'splice':
58+
return ArraySplice;
59+
case 'copyWithin':
60+
return ArrayCopyWithin;
61+
}
62+
}
63+
64+
let mutationWarningsSilenced = false;
65+
66+
// Warn if the user tries to mutate tmpl.stylesheets, e.g.:
67+
// `tmpl.stylesheets.push(someStylesheetFunction)`
68+
function warnOnArrayMutation(stylesheets: TemplateStylesheetFactories) {
69+
// We can't handle users calling Array.prototype.slice.call(tmpl.stylesheets), but
70+
// we can at least warn when they use the most common mutation methods.
71+
for (const prop of ARRAY_MUTATION_METHODS) {
72+
const originalArrayMethod = getOriginalArrayMethod(prop);
73+
stylesheets[prop] = function arrayMutationWarningWrapper() {
74+
logError(
75+
`Mutating the "stylesheets" array on a template function ` +
76+
`is deprecated and may be removed in a future version of LWC.`
77+
);
78+
// @ts-ignore
79+
return originalArrayMethod.apply(this, arguments);
80+
};
81+
}
82+
}
83+
84+
// TODO [#2782]: eventually freezeTemplate() will _actually_ freeze the tmpl object. Today it
85+
// just warns on mutation.
86+
export function freezeTemplate(tmpl: Template) {
87+
if (process.env.NODE_ENV !== 'production') {
88+
if (!isUndefined(tmpl.stylesheets)) {
89+
warnOnArrayMutation(tmpl.stylesheets);
90+
}
91+
for (const prop of TEMPLATE_PROPS) {
92+
let value = tmpl[prop];
93+
defineProperty(tmpl, prop, {
94+
enumerable: true,
95+
configurable: true,
96+
get() {
97+
return value;
98+
},
99+
set(newValue) {
100+
if (!mutationWarningsSilenced) {
101+
logError(
102+
`Dynamically setting the "${prop}" property on a template function ` +
103+
`is deprecated and may be removed in a future version of LWC.`
104+
);
105+
}
106+
value = newValue;
107+
},
108+
});
109+
}
110+
111+
const originalDescriptor = getOwnPropertyDescriptor(tmpl, 'stylesheetTokens');
112+
defineProperty(tmpl, 'stylesheetTokens', {
113+
enumerable: true,
114+
configurable: true,
115+
get: originalDescriptor!.get,
116+
set(value) {
117+
logError(
118+
`Dynamically setting the "stylesheetTokens" property on a template function ` +
119+
`is deprecated and may be removed in a future version of LWC.`
120+
);
121+
// Avoid logging twice (for both stylesheetToken and stylesheetTokens)
122+
mutationWarningsSilenced = true;
123+
originalDescriptor!.set!.call(this, value);
124+
mutationWarningsSilenced = false;
125+
},
126+
});
127+
}
128+
}

packages/@lwc/engine-core/src/framework/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export { profilerControl as __unstable__ProfilerControl } from './profiler';
4141
export { getUpgradableConstructor } from './upgradable-element';
4242
export { swapTemplate, swapComponent, swapStyle } from './hot-swaps';
4343
export { setHooks } from './overridable-hooks';
44+
export { freezeTemplate } from './freeze-template';
4445

4546
// Experimental or Internal APIs
4647
export { getComponentConstructor } from './get-component-constructor';

packages/@lwc/engine-core/src/framework/secure-template.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export function registerTemplate(tpl: Template): Template {
3434
// on top of stylesheetToken for anyone who is accessing the old internal API.
3535
// Details: https://salesforce.quip.com/v1rmAFu2cKAr
3636
defineProperty(tpl, 'stylesheetTokens', {
37+
enumerable: true,
38+
configurable: true,
3739
get() {
3840
const { stylesheetToken } = this;
3941
if (isUndefined(stylesheetToken)) {

packages/@lwc/engine-dom/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
setFeatureFlag,
2424
setFeatureFlagForTest,
2525
registerTemplate,
26+
freezeTemplate,
2627
registerComponent,
2728
registerDecorators,
2829
sanitizeAttribute,

packages/@lwc/engine-server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
setFeatureFlag,
2323
setFeatureFlagForTest,
2424
registerTemplate,
25+
freezeTemplate,
2526
registerComponent,
2627
registerDecorators,
2728
sanitizeAttribute,

packages/@lwc/shared/src/language.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,20 @@ const {
2323
const { isArray } = Array;
2424

2525
const {
26+
copyWithin: ArrayCopyWithin,
27+
fill: ArrayFill,
2628
filter: ArrayFilter,
2729
find: ArrayFind,
2830
indexOf: ArrayIndexOf,
2931
join: ArrayJoin,
3032
map: ArrayMap,
33+
pop: ArrayPop,
3134
push: ArrayPush,
3235
reduce: ArrayReduce,
3336
reverse: ArrayReverse,
37+
shift: ArrayShift,
3438
slice: ArraySlice,
39+
sort: ArraySort,
3540
splice: ArraySplice,
3641
unshift: ArrayUnshift,
3742
forEach,
@@ -49,13 +54,18 @@ const {
4954
export {
5055
ArrayFilter,
5156
ArrayFind,
57+
ArrayFill,
5258
ArrayIndexOf,
59+
ArrayCopyWithin,
5360
ArrayJoin,
5461
ArrayMap,
62+
ArrayPop,
5563
ArrayPush,
5664
ArrayReduce,
5765
ArrayReverse,
66+
ArrayShift,
5867
ArraySlice,
68+
ArraySort,
5969
ArraySplice,
6070
ArrayUnshift,
6171
assign,

packages/integration-karma/helpers/test-utils.js

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,28 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) {
9393
},
9494
};
9595
},
96-
compare: function compare(actual, expectedMessage) {
97-
function matchMessage(message) {
96+
compare: function compare(actual, expectedMessages) {
97+
function matchMessage(message, expectedMessage) {
9898
if (typeof expectedMessage === 'string') {
9999
return message === expectedMessage;
100100
} else {
101101
return expectedMessage.test(message);
102102
}
103103
}
104104

105+
if (!Array.isArray(expectedMessages)) {
106+
expectedMessages = [expectedMessages];
107+
}
108+
105109
if (typeof actual !== 'function') {
106110
throw new Error('Expected function to throw error.');
107111
} else if (
108-
typeof expectedMessage !== 'string' &&
109-
!(expectedMessage instanceof RegExp)
112+
expectedMessages.some(function (message) {
113+
return typeof message !== 'string' && !(message instanceof RegExp);
114+
})
110115
) {
111116
throw new Error(
112-
'Expected a string or a RegExp to compare the thrown error against.'
117+
'Expected a string or a RegExp to compare the thrown error against, or an array of such.'
113118
);
114119
}
115120

@@ -147,36 +152,39 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) {
147152
return fail(
148153
'Expected console.' +
149154
methodName +
150-
' to called once with "' +
151-
expectedMessage +
152-
'", but was never called.'
155+
' to called with ' +
156+
JSON.stringify(expectedMessages) +
157+
', but was never called.'
153158
);
154-
} else if (callsArgs.length === 1) {
155-
var actualMessage = formatConsoleCall(callsArgs[0]);
156-
157-
if (!matchMessage(actualMessage)) {
159+
} else {
160+
if (callsArgs.length !== expectedMessages.length) {
158161
return fail(
159162
'Expected console.' +
160163
methodName +
161-
' to be called with "' +
162-
expectedMessage +
163-
'", but was called with "' +
164-
actualMessage +
165-
'".'
164+
' to be called ' +
165+
expectedMessages.length +
166+
' time(s), but was called ' +
167+
callsArgs.length +
168+
' time(s).'
166169
);
167-
} else {
168-
return pass();
169170
}
170-
} else {
171-
return fail(
172-
'Expected console.' +
173-
methodName +
174-
' to never called, but it was called ' +
175-
callsArgs.length +
176-
' with ' +
177-
formattedCalls +
178-
'.'
179-
);
171+
for (var i = 0; i < callsArgs.length; i++) {
172+
var callsArg = callsArgs[i];
173+
var expectedMessage = expectedMessages[i];
174+
var actualMessage = formatConsoleCall(callsArg);
175+
if (!matchMessage(actualMessage, expectedMessage)) {
176+
return fail(
177+
'Expected console.' +
178+
methodName +
179+
' to be called with "' +
180+
expectedMessage +
181+
'", but was called with "' +
182+
actualMessage +
183+
'".'
184+
);
185+
}
186+
}
187+
return pass();
180188
}
181189
}
182190
},

0 commit comments

Comments
 (0)