Skip to content

Commit f857d10

Browse files
feat: tests
1 parent b95f94f commit f857d10

File tree

16 files changed

+361
-0
lines changed

16 files changed

+361
-0
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Copyright (c) 2025, 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+
isUndefined,
9+
getPrototypeOf,
10+
keys,
11+
getContextKeys,
12+
ArrayFilter,
13+
ContextEventName,
14+
isTrustedContext,
15+
} from '@lwc/shared';
16+
import { type VM } from '../vm';
17+
import { logErrorOnce } from '../../shared/logger';
18+
import type { Signal } from '@lwc/signals';
19+
import type { RendererAPI } from '../renderer';
20+
import type { ShouldContinueBubbling } from '../wiring/types';
21+
22+
type ContextProvidedCallback = (contextSignal?: Signal<unknown>) => void;
23+
type ContextVarieties = Map<unknown, Signal<unknown>>;
24+
25+
class ContextConnector<T extends object> {
26+
component: T;
27+
#renderer: RendererAPI;
28+
#providedContextVarieties: ContextVarieties;
29+
30+
constructor(component: T, providedContextVarieties: ContextVarieties, renderer: RendererAPI) {
31+
this.component = component;
32+
this.#renderer = renderer;
33+
this.#providedContextVarieties = providedContextVarieties;
34+
}
35+
36+
provideContext<T extends object>(
37+
contextVariety: T,
38+
providedContextSignal: Signal<unknown>
39+
): void {
40+
// registerContextProvider is called one time when the component is first provided context.
41+
// The component is then listening for consumers to consume the provided context.
42+
if (this.#providedContextVarieties.size === 0) {
43+
this.#renderer.registerContextProvider(
44+
this.component,
45+
ContextEventName,
46+
(payload): ShouldContinueBubbling => {
47+
// This callback is invoked when the provided context is consumed somewhere down
48+
// in the component's subtree.
49+
return payload.setNewContext(this.#providedContextVarieties);
50+
}
51+
);
52+
}
53+
54+
if (this.#providedContextVarieties.has(contextVariety)) {
55+
logErrorOnce(
56+
'Multiple contexts of the same variety were provided. Only the first context will be used.'
57+
);
58+
return;
59+
}
60+
this.#providedContextVarieties.set(contextVariety, providedContextSignal);
61+
}
62+
63+
consumeContext<T extends object>(
64+
contextVariety: T,
65+
contextProvidedCallback: ContextProvidedCallback
66+
): void {
67+
this.#renderer.registerContextConsumer(this.component, ContextEventName, {
68+
setNewContext: (providerContextVarieties: ContextVarieties): ShouldContinueBubbling => {
69+
// If the provider has the specified context variety, then it is consumed
70+
// and true is called to stop bubbling.
71+
if (providerContextVarieties.has(contextVariety)) {
72+
contextProvidedCallback(providerContextVarieties.get(contextVariety));
73+
return true;
74+
}
75+
// Return false as context has not been found/consumed
76+
// and the consumer should continue traversing the context tree
77+
return false;
78+
},
79+
});
80+
}
81+
}
82+
83+
export function connectContext(vm: VM) {
84+
const contextKeys = getContextKeys();
85+
86+
if (isUndefined(contextKeys)) {
87+
return;
88+
}
89+
90+
const { connectContext } = contextKeys;
91+
const { renderer, elm, component } = vm;
92+
93+
const enumerableKeys = keys(getPrototypeOf(component));
94+
const contextfulFieldsOrProps = ArrayFilter.call(enumerableKeys, (propName) =>
95+
isTrustedContext((component as any)[propName])
96+
);
97+
98+
if (contextfulFieldsOrProps.length === 0) {
99+
return;
100+
}
101+
102+
const providedContextVarieties: ContextVarieties = new Map();
103+
104+
for (let i = 0; i < contextfulFieldsOrProps.length; i++) {
105+
(component as any)[contextfulFieldsOrProps[i]][connectContext](
106+
new ContextConnector(elm, providedContextVarieties, renderer)
107+
);
108+
}
109+
}
110+
111+
export function disconnectContext(vm: VM) {
112+
const contextKeys = getContextKeys();
113+
114+
if (!contextKeys) {
115+
return;
116+
}
117+
118+
const { disconnectContext } = contextKeys;
119+
const { component } = vm;
120+
121+
const enumerableKeys = keys(getPrototypeOf(component));
122+
const contextfulFieldsOrProps = ArrayFilter.call(enumerableKeys, (propName) =>
123+
isTrustedContext((component as any)[propName])
124+
);
125+
126+
if (contextfulFieldsOrProps.length === 0) {
127+
return;
128+
}
129+
130+
for (let i = 0; i < contextfulFieldsOrProps.length; i++) {
131+
(component as any)[contextfulFieldsOrProps[i]][disconnectContext](component);
132+
}
133+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
export default {
2+
// server is expected to generate the same console error as the client
3+
expectedSSRConsoleCalls: {
4+
error: [
5+
'Multiple contexts of the same variety were provided. Only the first context will be used.',
6+
],
7+
warn: [],
8+
},
9+
snapshot(target) {
10+
const grandparent = target.shadowRoot.querySelector('x-grandparent');
11+
const detachedChild = target.shadowRoot.querySelector('x-child');
12+
const firstParent = grandparent.shadowRoot.querySelectorAll('x-parent')[0];
13+
const secondParent = grandparent.shadowRoot.querySelectorAll('x-parent')[1];
14+
const childOfFirstParent = firstParent.shadowRoot.querySelector('x-child');
15+
const childOfSecondParent = secondParent.shadowRoot.querySelector('x-child');
16+
17+
return {
18+
components: {
19+
grandparent,
20+
firstParent,
21+
secondParent,
22+
childOfFirstParent,
23+
childOfSecondParent,
24+
},
25+
detachedChild,
26+
};
27+
},
28+
test(target, snapshot, consoleCalls) {
29+
assertCorrectContext(
30+
snapshot,
31+
'grandparent provided value, another grandparent provided value'
32+
);
33+
assertContextShadowed(snapshot, 'grandparent provided value', 'shadow value');
34+
assertContextDisconnected(target, snapshot);
35+
// Expect an error as one context was generated twice. Expect no hydration warnings.
36+
TestUtils.expectConsoleCalls(consoleCalls, {
37+
error: [
38+
'Multiple contexts of the same variety were provided. Only the first context will be used.',
39+
],
40+
warn: [],
41+
});
42+
},
43+
};
44+
45+
function assertCorrectContext(snapshot, expectedContext) {
46+
// Assert the provided context is correct
47+
Object.values(snapshot.components).forEach((component) => {
48+
expect(component.shadowRoot.querySelector('div').textContent)
49+
.withContext(`${component.tagName} should have the correct context`)
50+
.toBe(expectedContext);
51+
});
52+
expect(snapshot.detachedChild.shadowRoot.querySelector('div').textContent).toBe(', ');
53+
}
54+
55+
function assertContextShadowed(snapshot, expectedInitialContext, shadowContext) {
56+
// Assert context is correct after shadowing
57+
snapshot.components.firstParent.context.value.value = shadowContext;
58+
Object.entries(snapshot.components).forEach(([key, component]) => {
59+
if (key === 'firstParent' || key === 'childOfFirstParent') {
60+
expect(component.context.value.value)
61+
.withContext(`${component.tagName} should have the correct context after shadowing`)
62+
.toBe(shadowContext);
63+
} else {
64+
expect(component.context.value.value)
65+
.withContext(`${component.tagName} should have the initial context after shadowing`)
66+
.toBe(expectedInitialContext);
67+
}
68+
});
69+
}
70+
71+
function assertContextDisconnected(target, snapshot) {
72+
// Assert context is disconnected
73+
Object.values(snapshot.components).forEach(
74+
(component) =>
75+
(component.disconnect = () => {
76+
expect(component.context.disconnectContextCalled)
77+
.withContext(`${component.tagName} should have disconnected the context`)
78+
.toBeTrue();
79+
})
80+
);
81+
target.showTree = false;
82+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LightningElement, api } from 'lwc';
2+
3+
export default class Base extends LightningElement {
4+
@api disconnect;
5+
6+
disconnectedCallback() {
7+
if (this.disconnect) {
8+
this.disconnect();
9+
}
10+
}
11+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<div>{context.value.value}, {anotherContext.value.value}</div>
3+
</template>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { api } from 'lwc';
2+
import Base from 'x/base';
3+
import { defineContext } from 'x/contextManager';
4+
import { parentContextFactory, anotherParentContextFactory } from 'x/parentContext';
5+
6+
export default class Child extends Base {
7+
@api context = defineContext(parentContextFactory)();
8+
@api anotherContext = defineContext(anotherParentContextFactory)();
9+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { setContextKeys, setTrustedContextSet } from 'lwc';
2+
3+
const connectContext = Symbol('connectContext');
4+
const disconnectContext = Symbol('disconnectContext');
5+
const trustedContext = new WeakSet();
6+
7+
setTrustedContextSet(trustedContext);
8+
setContextKeys({ connectContext, disconnectContext });
9+
10+
class MockSignal {
11+
subscribers = new Set();
12+
13+
constructor(initialValue) {
14+
this._value = initialValue;
15+
}
16+
17+
set value(newValue) {
18+
this._value = newValue;
19+
this.notify();
20+
}
21+
22+
get value() {
23+
return this._value;
24+
}
25+
26+
subscribe(onUpdate) {
27+
this.subscribers.add(onUpdate);
28+
}
29+
30+
notify() {
31+
for (const subscriber of this.subscribers) {
32+
subscriber(this.value);
33+
}
34+
}
35+
}
36+
37+
class MockContextSignal {
38+
disconnectContextCalled = false;
39+
40+
constructor(initialValue, contextDefinition, fromContext) {
41+
this.value = new MockSignal(initialValue);
42+
this.contextDefinition = contextDefinition;
43+
this.fromContext = fromContext;
44+
trustedContext.add(this);
45+
}
46+
[connectContext](runtimeAdapter) {
47+
runtimeAdapter.provideContext(this.contextDefinition, this);
48+
if (this.fromContext) {
49+
runtimeAdapter.consumeContext(this.fromContext, (providedContextSignal) => {
50+
this.value.value = providedContextSignal.value.value;
51+
providedContextSignal.value.subscribe(
52+
(updatedValue) => (this.value.value = updatedValue)
53+
);
54+
});
55+
}
56+
}
57+
[disconnectContext]() {
58+
this.disconnectContextCalled = true;
59+
}
60+
}
61+
62+
export const defineContext = (fromContext) => {
63+
const contextDefinition = (initialValue) =>
64+
new MockContextSignal(initialValue, contextDefinition, fromContext);
65+
return contextDefinition;
66+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<div>{context.value.value}, {anotherContext.value.value}</div>
3+
<x-parent></x-parent>
4+
<x-parent></x-parent>
5+
</template>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { api } from 'lwc';
2+
import Base from 'x/base';
3+
import { grandparentContextFactory, anotherGrandparentContextFactory } from 'x/grandparentContext';
4+
5+
export default class Grandparent extends Base {
6+
@api context = grandparentContextFactory('grandparent provided value');
7+
@api anotherContext = anotherGrandparentContextFactory('another grandparent provided value');
8+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { defineContext } from 'x/contextManager';
2+
3+
export const grandparentContextFactory = defineContext();
4+
export const anotherGrandparentContextFactory = defineContext();
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<x-grandparent lwc:if={showTree}></x-grandparent>
3+
<x-child></x-child>
4+
<x-too-much-context></x-too-much-context>
5+
</template>

0 commit comments

Comments
 (0)