Skip to content

Commit 25b6a98

Browse files
backport of context lifecycle fix (#5546)
* fix: version * fix: context lifecycle re-render * fix: context lifecycle tests * fix: context lifecycle tests
1 parent 18f8ce5 commit 25b6a98

File tree

10 files changed

+198
-10
lines changed

10 files changed

+198
-10
lines changed

packages/@lwc/engine-core/src/framework/modules/context.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
*/
77
import {
88
isUndefined,
9-
getPrototypeOf,
109
keys,
1110
getContextKeys,
1211
ArrayFilter,
1312
ContextEventName,
1413
isTrustedContext,
1514
type ContextProvidedCallback,
1615
type ContextBinding as IContextBinding,
16+
getPrototypeOf,
1717
} from '@lwc/shared';
1818
import { type VM } from '../vm';
1919
import { logWarnOnce } from '../../shared/logger';
@@ -81,6 +81,38 @@ class ContextBinding<C extends object> implements IContextBinding<C> {
8181
}
8282

8383
export function connectContext(vm: VM) {
84+
/**
85+
* If ENABLE_LEGACY_CONTEXT_CONNECTION is true, enumerates directly on the component
86+
* which can result in the component lifecycle observing properties that are not typically observed.
87+
* See PR #5536 for more information.
88+
*/
89+
if (lwcRuntimeFlags.ENABLE_LEGACY_CONTEXT_CONNECTION) {
90+
connect(vm, keys(getPrototypeOf(vm.component)), vm.component);
91+
} else {
92+
// Non-decorated objects
93+
connect(vm, keys(vm.cmpFields), vm.cmpFields);
94+
// Decorated objects like @api context
95+
connect(vm, keys(vm.cmpProps), vm.cmpProps);
96+
}
97+
}
98+
99+
export function disconnectContext(vm: VM) {
100+
/**
101+
* If ENABLE_LEGACY_CONTEXT_CONNECTION is true, enumerates directly on the component
102+
* which can result in the component lifecycle observing properties that are not typically observed.
103+
* See PR #5536 for more information.
104+
*/
105+
if (lwcRuntimeFlags.ENABLE_LEGACY_CONTEXT_CONNECTION) {
106+
connect(vm, keys(getPrototypeOf(vm.component)), vm.component);
107+
} else {
108+
// Non-decorated objects
109+
disconnect(vm, keys(vm.cmpFields), vm.cmpFields);
110+
// Decorated objects like @api context
111+
disconnect(vm, keys(vm.cmpProps), vm.cmpProps);
112+
}
113+
}
114+
115+
function connect(vm: VM, enumerableKeys: string[], contextContainer: any) {
84116
const contextKeys = getContextKeys();
85117

86118
if (isUndefined(contextKeys)) {
@@ -90,9 +122,8 @@ export function connectContext(vm: VM) {
90122
const { connectContext } = contextKeys;
91123
const { component } = vm;
92124

93-
const enumerableKeys = keys(getPrototypeOf(component));
94125
const contextfulKeys = ArrayFilter.call(enumerableKeys, (enumerableKey) =>
95-
isTrustedContext((component as any)[enumerableKey])
126+
isTrustedContext(contextContainer[enumerableKey])
96127
);
97128

98129
if (contextfulKeys.length === 0) {
@@ -103,7 +134,7 @@ export function connectContext(vm: VM) {
103134

104135
try {
105136
for (let i = 0; i < contextfulKeys.length; i++) {
106-
(component as any)[contextfulKeys[i]][connectContext](
137+
contextContainer[contextfulKeys[i]][connectContext](
107138
new ContextBinding(vm, component, providedContextVarieties)
108139
);
109140
}
@@ -116,7 +147,7 @@ export function connectContext(vm: VM) {
116147
}
117148
}
118149

119-
export function disconnectContext(vm: VM) {
150+
function disconnect(vm: VM, enumerableKeys: string[], contextContainer: any) {
120151
const contextKeys = getContextKeys();
121152

122153
if (!contextKeys) {
@@ -126,9 +157,8 @@ export function disconnectContext(vm: VM) {
126157
const { disconnectContext } = contextKeys;
127158
const { component } = vm;
128159

129-
const enumerableKeys = keys(getPrototypeOf(component));
130160
const contextfulKeys = ArrayFilter.call(enumerableKeys, (enumerableKey) =>
131-
isTrustedContext((component as any)[enumerableKey])
161+
isTrustedContext(contextContainer[enumerableKey])
132162
);
133163

134164
if (contextfulKeys.length === 0) {
@@ -137,7 +167,7 @@ export function disconnectContext(vm: VM) {
137167

138168
try {
139169
for (let i = 0; i < contextfulKeys.length; i++) {
140-
(component as any)[contextfulKeys[i]][disconnectContext](component);
170+
contextContainer[contextfulKeys[i]][disconnectContext](component);
141171
}
142172
} catch (err: any) {
143173
logWarnOnce(

packages/@lwc/features/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const features: FeatureFlagMap = {
2424
DISABLE_SCOPE_TOKEN_VALIDATION: null,
2525
LEGACY_LOCKER_ENABLED: null,
2626
DISABLE_LEGACY_VALIDATION: null,
27+
ENABLE_LEGACY_CONTEXT_CONNECTION: null,
2728
};
2829

2930
if (!(globalThis as any).lwcRuntimeFlags) {

packages/@lwc/features/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ export interface FeatureFlagMap {
100100
* If false or unset, then the value of the `LEGACY_LOCKER_ENABLED` flag is used.
101101
*/
102102
DISABLE_LEGACY_VALIDATION: FeatureFlagValue;
103+
104+
/**
105+
* If true, enables legacy context connection and disconnection which can result in the component lifecycle
106+
* observing properties that are not typically observed. ENABLE_EXPERIMENTAL_SIGNALS must also be enabled for
107+
* this flag to have an effect. See PR #5536 for more information.
108+
*/
109+
ENABLE_LEGACY_CONTEXT_CONNECTION: FeatureFlagValue;
103110
}
104111

105112
export type FeatureFlagName = keyof FeatureFlagMap;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { createElement, setFeatureFlagForTest } from 'lwc';
2+
3+
import Test from 'x/test';
4+
5+
// A bug (W-19830319) has shown that if child component properties are incorrectly marked for observation by the lifecycle, they can trigger the parent component to re-render
6+
// and this will cause undesired effects. This bug manifested due to context connection/disconnection erroneously accessing properties, but it could theoretically occur in any instance where
7+
// the "live" component property is accessed instead of via vm.cmpFields / vm.cmpProps.
8+
describe('Unobserved properties should trigger additional re-renders', () => {
9+
it('when signals is enabled and legacy context connection is enabled', async () => {
10+
// This is on by default, but enabled here to confirm the case
11+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true);
12+
setFeatureFlagForTest('ENABLE_LEGACY_CONTEXT_CONNECTION', true);
13+
14+
const elm = createElement('x-test', { is: Test });
15+
document.body.appendChild(elm);
16+
// Specific sequence (it is rather nuanced and requires 1. parent to switch templates and 2. child to mutate a property in a setTimeout function or similar):
17+
// 1. The template change results in the parent component being marked as dirty.
18+
// 1a. Marking the parent as dirty sets the currentReactiveObserver to the parent, here: https://github.com/salesforce/lwc/blob/master/packages/%40lwc/engine-core/src/libs/mutation-tracker/index.ts#L83
19+
// 2. The new template doesn't contain the child so disconnectContext is called on the child component. The BUG: If the child properties are incorrectly observed then riggering disconnectContext marks all child properties
20+
// for observation using the currentReactiveObserver of the parent set in 1a. here: https://github.com/salesforce/lwc/blob/master/packages/%40lwc/engine-core/src/libs/mutation-tracker/index.ts#L60
21+
// 3. Next, a delayed property mutation inside the child component's renderedCallback occurs and this delayed (post disconnection) mutation triggers valueMutated, where all the parent properties are registered listeners.
22+
// That happens here: https://github.com/salesforce/lwc/blob/master/packages/%40lwc/engine-core/src/libs/mutation-tracker/index.ts#L41
23+
// 3b. This causes the parent component to re-render.
24+
elm.switchToEmptyTemplate();
25+
26+
// Before the bugfix (W-19830319/PR-5536), expecting 2 renders when signals was enabled would fail.
27+
// An erroneous 3rd render would occur when signals was enabled due to the behavior described above.
28+
setTimeout(() => {
29+
expect(elm.renderCount).toBe(3); // because ENABLE_LEGACY_SIGNAL_CONTEXT_VALIDATION = true this will render 3 times (incorrect)
30+
});
31+
});
32+
33+
it('when signals is enabled, legacy context connection is true and detached rehydration is enabled', async () => {
34+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true);
35+
setFeatureFlagForTest('ENABLE_LEGACY_CONTEXT_CONNECTION', true);
36+
setFeatureFlagForTest('DISABLE_DETACHED_REHYDRATION', true);
37+
38+
const elm = createElement('x-test', { is: Test });
39+
document.body.appendChild(elm);
40+
elm.switchToEmptyTemplate();
41+
42+
// Enabling detached rehydration does not change the behavior (bug still exists and the third render occurs)
43+
setTimeout(() => {
44+
expect(elm.renderCount).toBe(3);
45+
});
46+
});
47+
48+
afterAll(() => {
49+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true);
50+
setFeatureFlagForTest('ENABLE_LEGACY_CONTEXT_CONNECTION', false);
51+
setFeatureFlagForTest('DISABLE_DETACHED_REHYDRATION', false);
52+
});
53+
});
54+
55+
describe('Unobserved properties should NOT trigger additional re-renders', () => {
56+
it('when signals is enabled, legacy context connection is disabled and detached rehydration is enabled', async () => {
57+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true);
58+
setFeatureFlagForTest('ENABLE_LEGACY_CONTEXT_CONNECTION', false);
59+
setFeatureFlagForTest('DISABLE_DETACHED_REHYDRATION', true);
60+
61+
const elm = createElement('x-test', { is: Test });
62+
document.body.appendChild(elm);
63+
elm.switchToEmptyTemplate();
64+
65+
// Enabling detached rehydration does not change the behavior
66+
setTimeout(() => {
67+
expect(elm.renderCount).toBe(2);
68+
});
69+
});
70+
71+
it('when signals is enabled and legacy context connection is disabled', async () => {
72+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true);
73+
setFeatureFlagForTest('ENABLE_LEGACY_CONTEXT_CONNECTION', false);
74+
75+
const elm = createElement('x-test', { is: Test });
76+
document.body.appendChild(elm);
77+
elm.switchToEmptyTemplate();
78+
79+
// CORRECT: This component should render twice as the corrected context connection is in use.
80+
setTimeout(() => {
81+
expect(elm.renderCount).toBe(2);
82+
});
83+
});
84+
85+
it('when signals is disabled', async () => {
86+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', false);
87+
88+
const elm = createElement('x-test', { is: Test });
89+
document.body.appendChild(elm);
90+
elm.switchToEmptyTemplate();
91+
92+
// CORRECT: This component should render twice as signals is disabled and there should be no observation bugs elsewhere in the lifecycle.
93+
setTimeout(() => {
94+
expect(elm.renderCount).toBe(2);
95+
});
96+
});
97+
98+
afterAll(() => {
99+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true);
100+
setFeatureFlagForTest('DISABLE_DETACHED_REHYDRATION', false);
101+
});
102+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LightningElement } from 'lwc';
2+
3+
export default class Test extends LightningElement {
4+
unobservedValue;
5+
6+
renderedCallback() {
7+
// The mutation of the unobserved property must occur in a later tick, which causes the parent to render once again if
8+
// the property is being incorrectly observed.
9+
setTimeout(() => (this.unobservedValue = 'mutated'));
10+
}
11+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<template>
2+
</template>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { LightningElement, api, setTrustedContextSet, setContextKeys } from 'lwc';
2+
import templateWithChild from './withChild.html';
3+
import emptyTemplate from './empty.html';
4+
5+
export const connectContext = Symbol.for('connectContext');
6+
export const disconnectContext = Symbol.for('disconnectContext');
7+
export const trustedContext = new WeakSet();
8+
9+
setTrustedContextSet(trustedContext);
10+
setContextKeys({ connectContext, disconnectContext });
11+
12+
export default class Test extends LightningElement {
13+
template = templateWithChild;
14+
@api renderCount = 0;
15+
16+
renderedCallback() {
17+
this.renderCount++;
18+
}
19+
20+
// The template is switched, which disconnects the child component and schedules this component for rehydration.
21+
// A bug (W-19830319) has shown that if child component properties are incorrectly marked for observation by the lifecycle, they can trigger the parent component to re-render
22+
// and this will cause undesired effects. This bug manifested due to context connection/disconnection, but it could theoretically occur in any instance where
23+
// the "live" component property is accessed instead of via vm.cmpFields / vm.cmpProps.
24+
@api switchToEmptyTemplate() {
25+
this.template = emptyTemplate;
26+
}
27+
28+
// The switch is done explicitly with a template, marking this component as dirty.
29+
render() {
30+
return this.template;
31+
}
32+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<x-child></x-child>
3+
</template>

scripts/bundlesize/bundlesize.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"files": [
33
{
44
"path": "packages/@lwc/engine-dom/dist/index.js",
5-
"maxSize": "24.72KB"
5+
"maxSize": "24.75KB"
66
},
77
{
88
"path": "packages/@lwc/synthetic-shadow/dist/index.js",

scripts/release/version.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ set -e
99

1010
VERSION="$1"
1111
BRANCH="release-version-$(date -u +'%Y-%m-%d-%H_%M')"
12-
BASE_BRANCH='master'
12+
BASE_BRANCH='winter26'
1313

1414
if [ -z "$VERSION" ]; then
1515
echo 'Specify a new version.'

0 commit comments

Comments
 (0)