Skip to content

Commit 7454013

Browse files
feat: CSR/SSRv1 context (#5356)
* chore: add tests, centralize coverage * chore: add stubs for unimplemented v2 exports * chore: simplified shadow test * fix: gate protection, review comments, malformed context handling * fix: doc review
1 parent 7417320 commit 7454013

File tree

37 files changed

+691
-41
lines changed

37 files changed

+691
-41
lines changed

packages/@lwc/engine-core/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,18 @@ This experimental API enables the removal of an object's observable membrane pro
121121
This experimental API enables the addition of a signal as a trusted signal. If the [ENABLE_EXPERIMENTAL_SIGNALS](https://github.com/salesforce/lwc/blob/master/packages/%40lwc/features/README.md#lwcfeatures) feature is enabled, any signal value change will trigger a re-render.
122122

123123
If `setTrustedSignalSet` is called more than once, it will throw an error. If it is never called, then no trusted signal validation will be performed. The same `setTrustedSignalSet` API must be called on both `@lwc/engine-dom` and `@lwc/signals`.
124+
125+
### setContextKeys
126+
127+
Not intended for external use. Enables another library to establish contextful relationships via the LWC component tree. The `connectContext` and `disconnectContext` symbols that are provided are later used to identify methods that facilitate the establishment and dissolution of these contextful relationships.
128+
129+
### setTrustedContextSet()
130+
131+
Not intended for external use. This experimental API enables the addition of context as trusted context. If the [ENABLE_EXPERIMENTAL_SIGNALS](https://github.com/salesforce/lwc/blob/master/packages/%40lwc/features/README.md#lwcfeatures) feature is enabled.
132+
133+
If `setTrustedContextSet` is called more than once, it will throw an error. If it is never called, then context will not be connected.
134+
135+
### ContextBinding
136+
137+
The context object's `connectContext` and `disconnectContext` methods are called with this object when contextful components are connected and disconnected. The ContextBinding exposes `provideContext` and `consumeContext`,
138+
enabling the provision/consumption of a contextful Signal of a specified variety for the associated component.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,5 @@ export { default as wire } from './decorators/wire';
7373
export { readonly } from './readonly';
7474

7575
export { setFeatureFlag, setFeatureFlagForTest } from '@lwc/features';
76-
export { setTrustedSignalSet } from '@lwc/shared';
76+
export { setContextKeys, setTrustedSignalSet, setTrustedContextSet } from '@lwc/shared';
7777
export type { Stylesheet, Stylesheets } from '@lwc/shared';
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
type ContextProvidedCallback,
16+
type ContextBinding as IContextBinding,
17+
} from '@lwc/shared';
18+
import { type VM } from '../vm';
19+
import { logWarnOnce } from '../../shared/logger';
20+
import type { Signal } from '@lwc/signals';
21+
import type { RendererAPI } from '../renderer';
22+
import type { ShouldContinueBubbling } from '../wiring/types';
23+
24+
type ContextVarieties = Map<unknown, Signal<unknown>>;
25+
26+
class ContextBinding<C extends object> implements IContextBinding<C> {
27+
component: C;
28+
#renderer: RendererAPI;
29+
#providedContextVarieties: ContextVarieties;
30+
#elm: HTMLElement;
31+
32+
constructor(vm: VM, component: C, providedContextVarieties: ContextVarieties) {
33+
this.component = component;
34+
this.#renderer = vm.renderer;
35+
this.#elm = vm.elm;
36+
this.#providedContextVarieties = providedContextVarieties;
37+
38+
// Register the component as a context provider.
39+
this.#renderer.registerContextProvider(
40+
this.#elm,
41+
ContextEventName,
42+
(contextConsumer): ShouldContinueBubbling => {
43+
// This callback is invoked when the provided context is consumed somewhere down
44+
// in the component's subtree.
45+
return contextConsumer.setNewContext(this.#providedContextVarieties);
46+
}
47+
);
48+
}
49+
50+
provideContext<V extends object>(
51+
contextVariety: V,
52+
providedContextSignal: Signal<unknown>
53+
): void {
54+
if (this.#providedContextVarieties.has(contextVariety)) {
55+
logWarnOnce(
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<V extends object>(
64+
contextVariety: V,
65+
contextProvidedCallback: ContextProvidedCallback
66+
): void {
67+
this.#renderer.registerContextConsumer(this.#elm, ContextEventName, {
68+
setNewContext: (providerContextVarieties: ContextVarieties): ShouldContinueBubbling => {
69+
// If the provider has the specified context variety, then it is consumed
70+
// and true is returned 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 { component } = vm;
92+
93+
const enumerableKeys = keys(getPrototypeOf(component));
94+
const contextfulKeys = ArrayFilter.call(enumerableKeys, (enumerableKey) =>
95+
isTrustedContext((component as any)[enumerableKey])
96+
);
97+
98+
if (contextfulKeys.length === 0) {
99+
return;
100+
}
101+
102+
const providedContextVarieties: ContextVarieties = new Map();
103+
104+
try {
105+
for (let i = 0; i < contextfulKeys.length; i++) {
106+
(component as any)[contextfulKeys[i]][connectContext](
107+
new ContextBinding(vm, component, providedContextVarieties)
108+
);
109+
}
110+
} catch (err: any) {
111+
logWarnOnce(
112+
`Attempted to connect to trusted context but received the following error: ${
113+
err.message
114+
}`
115+
);
116+
}
117+
}
118+
119+
export function disconnectContext(vm: VM) {
120+
const contextKeys = getContextKeys();
121+
122+
if (!contextKeys) {
123+
return;
124+
}
125+
126+
const { disconnectContext } = contextKeys;
127+
const { component } = vm;
128+
129+
const enumerableKeys = keys(getPrototypeOf(component));
130+
const contextfulKeys = ArrayFilter.call(enumerableKeys, (enumerableKey) =>
131+
isTrustedContext((component as any)[enumerableKey])
132+
);
133+
134+
if (contextfulKeys.length === 0) {
135+
return;
136+
}
137+
138+
try {
139+
for (let i = 0; i < contextfulKeys.length; i++) {
140+
(component as any)[contextfulKeys[i]][disconnectContext](component);
141+
}
142+
} catch (err: any) {
143+
logWarnOnce(
144+
`Attempted to disconnect from trusted context but received the following error: ${
145+
err.message
146+
}`
147+
);
148+
}
149+
}

packages/@lwc/engine-core/src/framework/mutation-tracker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function componentValueObserved(vm: VM, key: PropertyKey, target: any = {
4242
isObject(target) &&
4343
!isNull(target) &&
4444
isTrustedSignal(target) &&
45+
process.env.IS_BROWSER &&
4546
// Only subscribe if a template is being rendered by the engine
4647
tro.isObserving()
4748
) {

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: MIT
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
66
*/
7-
import type { WireContextSubscriptionPayload } from './wiring';
7+
import type { WireContextSubscriptionCallback, WireContextSubscriptionPayload } from './wiring';
88

99
export type HostNode = any;
1010
export type HostElement = any;
@@ -76,6 +76,11 @@ export interface RendererAPI {
7676
) => E;
7777
defineCustomElement: (tagName: string, isFormAssociated: boolean) => void;
7878
ownerDocument(elm: E): Document;
79+
registerContextProvider: (
80+
element: E,
81+
adapterContextToken: string,
82+
onContextSubscription: WireContextSubscriptionCallback
83+
) => void;
7984
registerContextConsumer: (
8085
element: E,
8186
adapterContextToken: string,

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { flushMutationLogsForVM, getAndFlushMutationLogs } from './mutation-logg
4949
import { connectWireAdapters, disconnectWireAdapters, installWireAdapters } from './wiring';
5050
import { VNodeType, isVFragment } from './vnodes';
5151
import { isReportingEnabled, report, ReportingEventId } from './reporting';
52+
import { connectContext, disconnectContext } from './modules/context';
5253
import type { VNodes, VCustomElement, VNode, VBaseElement, VStaticPartElement } from './vnodes';
5354
import type { ReactiveObserver } from './mutation-tracker';
5455
import type {
@@ -702,6 +703,12 @@ export function runConnectedCallback(vm: VM) {
702703
if (hasWireAdapters(vm)) {
703704
connectWireAdapters(vm);
704705
}
706+
707+
if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
708+
// Setup context before connected callback is executed
709+
connectContext(vm);
710+
}
711+
705712
const { connectedCallback } = vm.def;
706713
if (!isUndefined(connectedCallback)) {
707714
logOperationStart(OperationId.ConnectedCallback, vm);
@@ -751,6 +758,11 @@ function runDisconnectedCallback(vm: VM) {
751758
if (process.env.NODE_ENV !== 'production') {
752759
assert.isTrue(vm.state !== VMState.disconnected, `${vm} must be inserted.`);
753760
}
761+
762+
if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
763+
disconnectContext(vm);
764+
}
765+
754766
if (isFalse(vm.isDirty)) {
755767
// this guarantees that if the component is reused/reinserted,
756768
// it will be re-rendered because we are disconnecting the reactivity

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@ export function createContextProviderWithRegister(
5454
consumerDisconnectedCallback(consumer);
5555
}
5656
};
57-
setDisconnectedCallback(disconnectCallback);
57+
setDisconnectedCallback?.(disconnectCallback);
5858

5959
consumerConnectedCallback(consumer);
60+
// Return true as the context is always consumed here and the consumer should
61+
// stop bubbling.
62+
return true;
6063
}
6164
);
6265
};
@@ -91,6 +94,9 @@ export function createContextWatcher(
9194
// eslint-disable-next-line @lwc/lwc-internal/no-invalid-todo
9295
// TODO: dev-mode validation of config based on the adapter.contextSchema
9396
callbackWhenContextIsReady(newContext);
97+
// Return true as the context is always consumed here and the consumer should
98+
// stop bubbling.
99+
return true;
94100
},
95101
setDisconnectedCallback(disconnectCallback: () => void) {
96102
// adds this callback into the disconnect bucket so it gets disconnected from parent

packages/@lwc/engine-core/src/framework/wiring/types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,15 @@ export interface WireDebugInfo {
5959
wasDataProvisionedForConfig: boolean;
6060
}
6161

62+
export type ShouldContinueBubbling = boolean;
63+
6264
export type WireContextSubscriptionCallback = (
6365
subscriptionPayload: WireContextSubscriptionPayload
64-
) => void;
66+
) => ShouldContinueBubbling;
6567

6668
export interface WireContextSubscriptionPayload {
67-
setNewContext(newContext: ContextValue): void;
68-
setDisconnectedCallback(disconnectCallback: () => void): void;
69+
setNewContext(newContext: ContextValue): ShouldContinueBubbling;
70+
setDisconnectedCallback?(disconnectCallback: () => void): void;
6971
}
7072

7173
export interface ContextConsumer {

packages/@lwc/engine-core/src/shared/logger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export function logError(message: string, vm?: VM) {
5151
log('error', message, vm, false);
5252
}
5353

54+
export function logErrorOnce(message: string, vm?: VM) {
55+
log('error', message, vm, true);
56+
}
57+
5458
export function logWarn(message: string, vm?: VM) {
5559
log('warn', message, vm, false);
5660
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ export {
3030
isComponentConstructor,
3131
parseFragment,
3232
parseSVGFragment,
33+
setContextKeys,
3334
setTrustedSignalSet,
35+
setTrustedContextSet,
3436
swapComponent,
3537
swapStyle,
3638
swapTemplate,

packages/@lwc/engine-dom/src/renderer/context.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import type {
1616

1717
export class WireContextSubscriptionEvent extends CustomEvent<undefined> {
1818
// These are initialized on the constructor via defineProperties.
19-
public readonly setNewContext!: (newContext: WireContextValue) => void;
20-
public readonly setDisconnectedCallback!: (disconnectCallback: () => void) => void;
19+
public readonly setNewContext!: (newContext: WireContextValue) => boolean;
20+
public readonly setDisconnectedCallback?: (disconnectCallback: () => void) => void;
2121

2222
constructor(
2323
adapterToken: string,
@@ -56,11 +56,15 @@ export function registerContextProvider(
5656
onContextSubscription: WireContextSubscriptionCallback
5757
) {
5858
addEventListener(elm, adapterContextToken, ((evt: WireContextSubscriptionEvent) => {
59-
evt.stopImmediatePropagation();
6059
const { setNewContext, setDisconnectedCallback } = evt;
61-
onContextSubscription({
62-
setNewContext,
63-
setDisconnectedCallback,
64-
});
60+
// If context subscription is successful, stop event propagation
61+
if (
62+
onContextSubscription({
63+
setNewContext,
64+
setDisconnectedCallback,
65+
})
66+
) {
67+
evt.stopImmediatePropagation();
68+
}
6569
}) as EventListener);
6670
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function createContextProvider(adapter: WireAdapterConstructor) {
2626
return createContextProviderWithRegister(adapter, registerContextProvider);
2727
}
2828

29-
function registerContextProvider(
29+
export function registerContextProvider(
3030
elm: HostElement | LightningElement,
3131
adapterContextToken: string,
3232
onContextSubscription: WireContextSubscriptionCallback
@@ -57,10 +57,10 @@ export function registerContextConsumer(
5757
const subscribeToProvider =
5858
currentNode[HostContextProvidersKey].get(adapterContextToken);
5959
if (!isUndefined(subscribeToProvider)) {
60-
subscribeToProvider(subscriptionPayload);
61-
// If we find a provider, we shouldn't continue traversing
62-
// looking for another provider.
63-
break;
60+
// If context subscription is successful, stop traversing to locate a provider
61+
if (subscribeToProvider(subscriptionPayload)) {
62+
break;
63+
}
6464
}
6565
}
6666

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export {
2929
isComponentConstructor,
3030
parseFragment,
3131
parseFragment as parseSVGFragment,
32+
setTrustedContextSet,
33+
setContextKeys,
3234
} from '@lwc/engine-core';
3335

3436
// Engine-server public APIs -----------------------------------------------------------------------

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
startTrackingMutations,
3636
stopTrackingMutations,
3737
} from './utils/mutation-tracking';
38-
import { registerContextConsumer } from './context';
38+
import { registerContextConsumer, registerContextProvider } from './context';
3939
import type { HostNode, HostElement, HostAttribute, HostChildNode } from './types';
4040
import type { LifecycleCallback } from '@lwc/engine-core';
4141

@@ -509,6 +509,7 @@ export const renderer = {
509509
insertStylesheet,
510510
assertInstanceOfHTMLElement,
511511
ownerDocument,
512+
registerContextProvider,
512513
registerContextConsumer,
513514
attachInternals,
514515
defineCustomElement: getUpgradableElement,

0 commit comments

Comments
 (0)