Skip to content

Commit 4ca7f2a

Browse files
fix: context property observation, tests (#5536)
* fix: context property observation, tests * fix: exception for SSRv1 * fix: pr name * fix: un-revert change * chore: expand bug explanation * chore: expand bug explanation * chore: expand bug explanation * fix: gate and related tests * fix: bundlesize
1 parent 7eb3476 commit 4ca7f2a

File tree

17 files changed

+268
-36
lines changed

17 files changed

+268
-36
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
@@ -25,6 +25,7 @@ const features: FeatureFlagMap = {
2525
LEGACY_LOCKER_ENABLED: null,
2626
DISABLE_LEGACY_VALIDATION: null,
2727
DISABLE_DETACHED_REHYDRATION: null,
28+
ENABLE_LEGACY_CONTEXT_CONNECTION: null,
2829
};
2930

3031
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
@@ -106,6 +106,13 @@ export interface FeatureFlagMap {
106106
* Applies to rehydration performed while flushing the rehydration queue.
107107
*/
108108
DISABLE_DETACHED_REHYDRATION: FeatureFlagValue;
109+
110+
/**
111+
* If true, enables legacy context connection and disconnection which can result in the component lifecycle
112+
* observing properties that are not typically observed. ENABLE_EXPERIMENTAL_SIGNALS must also be enabled for
113+
* this flag to have an effect. See PR #5536 for more information.
114+
*/
115+
ENABLE_LEGACY_CONTEXT_CONNECTION: FeatureFlagValue;
109116
}
110117

111118
export type FeatureFlagName = keyof FeatureFlagMap;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {
2+
setContextKeys,
3+
setTrustedContextSet,
4+
__dangerous_do_not_use_addTrustedContext,
5+
} from 'lwc';
6+
7+
export const connectContext = Symbol.for('connectContext');
8+
export const disconnectContext = Symbol.for('disconnectContext');
9+
export const trustedContext = new WeakSet();
10+
11+
export function initContext() {
12+
try {
13+
setTrustedContextSet(trustedContext);
14+
setContextKeys({ connectContext, disconnectContext });
15+
} catch {
16+
// Context already initialized, ignore
17+
}
18+
}
19+
20+
export function addTrustedContext(context) {
21+
__dangerous_do_not_use_addTrustedContext(context);
22+
}

packages/@lwc/integration-not-karma/helpers/setup.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
// This import ensures that the global `Mocha` object is present for mutation.
22
import { JestAsymmetricMatchers, JestChaiExpect, JestExtend } from '@vitest/expect';
33
import * as chai from 'chai';
4+
import { setFeatureFlagForTest } from 'lwc';
45
import { registerCustomMatchers } from './matchers/index.js';
56
import { initSignals } from './signals.js';
7+
import { initContext } from './context.js';
68

79
initSignals();
10+
initContext();
11+
12+
// Enabling signals by default to increase coverage
13+
setFeatureFlagForTest('ENABLE_EXPERIMENTAL_SIGNALS', true);
814

915
// allows using expect.extend instead of chai.use to extend plugins
1016
chai.use(JestExtend);

packages/@lwc/integration-not-karma/test-hydration/context/index.spec.js

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,19 @@ export default {
3939
// Assert context is disconnected when components are removed
4040
assertContextDisconnected(target, snapshot);
4141

42-
// Expect an error as one context was generated twice.
43-
// Expect an error as one context was malformed (did not define connectContext or disconnectContext methods).
44-
// Expect server/client context output parity (no hydration warnings)
45-
expectConsoleCalls(consoleCalls, {
46-
error: [],
47-
warn: [
48-
'Attempted to connect to trusted context but received the following error',
49-
'Multiple contexts of the same variety were provided. Only the first context will be used.',
50-
],
51-
});
42+
// Legacy SSRv1 does not support context when inherited
43+
if (!process.env.ENGINE_SERVER) {
44+
// Expect an error as one context was generated twice.
45+
// Expect an error as one context was malformed (did not define connectContext or disconnectContext methods).
46+
// Expect server/client context output parity (no hydration warnings)
47+
expectConsoleCalls(consoleCalls, {
48+
error: [],
49+
warn: [
50+
'Attempted to connect to trusted context but received the following error',
51+
'Multiple contexts of the same variety were provided. Only the first context will be used.',
52+
],
53+
});
54+
}
5255
},
5356
};
5457

packages/@lwc/integration-not-karma/test-hydration/context/x/base/base.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@ import { LightningElement, api } from 'lwc';
22

33
export default class Base extends LightningElement {
44
@api disconnect;
5+
// Tests that non-decorated and inherited context works correctly
6+
_context;
7+
// Tests that decorated inherited context works correctly
8+
@api anotherContext;
9+
10+
constructor(context, anotherContext) {
11+
super();
12+
this.context = context;
13+
this.anotherContext = anotherContext;
14+
}
15+
16+
@api set context(value) {
17+
this._context = value;
18+
}
19+
20+
get context() {
21+
return this._context;
22+
}
523

624
disconnectedCallback() {
725
if (this.disconnect) {

packages/@lwc/integration-not-karma/test-hydration/context/x/child/child.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { defineContext } from 'x/contextManager';
44
import { parentContextFactory, anotherParentContextFactory } from 'x/parentContext';
55

66
export default class Child extends Base {
7-
@api context = defineContext(parentContextFactory)();
87
@api anotherContext = defineContext(anotherParentContextFactory)();
8+
9+
constructor() {
10+
super(defineContext(parentContextFactory)());
11+
}
912
}

packages/@lwc/integration-not-karma/test-hydration/context/x/contextManager/contextManager.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import {
2-
setContextKeys,
3-
setTrustedContextSet,
4-
__dangerous_do_not_use_addTrustedContext,
5-
} from 'lwc';
2+
connectContext,
3+
disconnectContext,
4+
addTrustedContext,
5+
initContext,
6+
} from '../../../../helpers/context';
67

7-
const connectContext = Symbol('connectContext');
8-
const disconnectContext = Symbol('disconnectContext');
9-
const trustedContext = new WeakSet();
10-
11-
setTrustedContextSet(trustedContext);
12-
setContextKeys({ connectContext, disconnectContext });
8+
initContext();
139

1410
class MockContextSignal {
1511
connectProvidedComponent;
@@ -20,7 +16,7 @@ class MockContextSignal {
2016
this.value = initialValue;
2117
this.contextDefinition = contextDefinition;
2218
this.fromContext = fromContext;
23-
__dangerous_do_not_use_addTrustedContext(this);
19+
addTrustedContext(this);
2420
}
2521
[connectContext](runtimeAdapter) {
2622
this.connectProvidedComponent = runtimeAdapter.component;
@@ -42,7 +38,7 @@ class MockContextSignal {
4238
// This is a malformed context signal that does not implement the connectContext or disconnectContext methods
4339
class MockMalformedContextSignal {
4440
constructor() {
45-
trustedContext.add(this);
41+
addTrustedContext(this);
4642
}
4743
}
4844

packages/@lwc/integration-not-karma/test-hydration/context/x/grandparent/grandparent.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import Base from 'x/base';
33
import { grandparentContextFactory, anotherGrandparentContextFactory } from 'x/grandparentContext';
44

55
export default class Grandparent extends Base {
6-
@api context = grandparentContextFactory('grandparent provided value');
76
@api anotherContext = anotherGrandparentContextFactory('another grandparent provided value');
7+
8+
constructor() {
9+
super(grandparentContextFactory('grandparent provided value'));
10+
}
811
}

0 commit comments

Comments
 (0)