Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@lwc/engine-core/src/framework/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,5 @@ export { default as wire } from './decorators/wire';
export { readonly } from './readonly';

export { setFeatureFlag, setFeatureFlagForTest } from '@lwc/features';
export { setTrustedSignalSet } from '@lwc/shared';
export { setContextKeys, setTrustedSignalSet, setTrustedContextSet } from '@lwc/shared';
export type { Stylesheet, Stylesheets } from '@lwc/shared';
135 changes: 135 additions & 0 deletions packages/@lwc/engine-core/src/framework/modules/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import {
isUndefined,
getPrototypeOf,
keys,
getContextKeys,
ArrayFilter,
ContextEventName,
isTrustedContext,
} from '@lwc/shared';
import { type VM } from '../vm';
import { logErrorOnce } from '../../shared/logger';
import type { Signal } from '@lwc/signals';
import type { RendererAPI } from '../renderer';
import type { ShouldContinueBubbling } from '../wiring/types';

type ContextProvidedCallback = (contextSignal?: Signal<unknown>) => void;
type ContextVarieties = Map<unknown, Signal<unknown>>;

class ContextConnector<C extends object> {
component: C;
#renderer: RendererAPI;
#providedContextVarieties: ContextVarieties;
#elm: HTMLElement;

constructor(vm: VM, component: C, providedContextVarieties: ContextVarieties) {
this.component = component;
this.#renderer = vm.renderer;
this.#elm = vm.elm;
this.#providedContextVarieties = providedContextVarieties;
}

provideContext<V extends object>(
contextVariety: V,
providedContextSignal: Signal<unknown>
): void {
// registerContextProvider is called one time when the component is first provided context.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this always true? For example, what if we instantiate a state manager (which implements ContextConnector) and attach it as a prop to two separate components?

I think we decided that "the latest context wins" in scenarios like that. But let's make sure the comments reflect that potential use-case, and make sure the implementation/tests cover that use case, as well.

Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc May 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this always true? For example, what if we instantiate a state manager (which implements ContextConnector) and attach it as a prop to two separate components?

IIUC, yes? A state manager doesn't instantiate ContextConnector but rather consumes it? This is based on the the experimental impl here.

There is one listener per contextful component (aka a component we know to have associated context).

  • The consumer event is fired and bubbles up, searching for the required context variety
  • This one listener on the provider calls back to the consumer and says "hey, is any of my context what you are looking for?"
  • If it there is a match, bubbling stops and if no match, the search continues

Please correct any misunderstandings I may have.

I think we decided that "the latest context wins" in scenarios like that. But let's make sure the comments reflect that potential use-case, and make sure the implementation/tests cover that use case, as well.

Yes that scenario is encapsulated below, here. I changed it to a warn to align with Signals implementation.

// The component is then listening for consumers to consume the provided context.
if (this.#providedContextVarieties.size === 0) {
this.#renderer.registerContextProvider(
this.#elm,
ContextEventName,
(payload): ShouldContinueBubbling => {
// This callback is invoked when the provided context is consumed somewhere down
// in the component's subtree.
return payload.setNewContext(this.#providedContextVarieties);
}
);
}

if (this.#providedContextVarieties.has(contextVariety)) {
logErrorOnce(
'Multiple contexts of the same variety were provided. Only the first context will be used.'
);
return;
}
this.#providedContextVarieties.set(contextVariety, providedContextSignal);
}

consumeContext<V extends object>(
contextVariety: V,
contextProvidedCallback: ContextProvidedCallback
): void {
this.#renderer.registerContextConsumer(this.#elm, ContextEventName, {
setNewContext: (providerContextVarieties: ContextVarieties): ShouldContinueBubbling => {
// If the provider has the specified context variety, then it is consumed
// and true is returned to stop bubbling.
if (providerContextVarieties.has(contextVariety)) {
contextProvidedCallback(providerContextVarieties.get(contextVariety));
return true;
}
// Return false as context has not been found/consumed
// and the consumer should continue traversing the context tree
return false;
},
});
}
}

export function connectContext(vm: VM) {
const contextKeys = getContextKeys();

if (isUndefined(contextKeys)) {
return;
}

const { connectContext } = contextKeys;
const { component } = vm;

const enumerableKeys = keys(getPrototypeOf(component));
const contextfulKeys = ArrayFilter.call(enumerableKeys, (enumerableKey) =>
isTrustedContext((component as any)[enumerableKey])
);

if (contextfulKeys.length === 0) {
return;
}

const providedContextVarieties: ContextVarieties = new Map();

for (let i = 0; i < contextfulKeys.length; i++) {
(component as any)[contextfulKeys[i]][connectContext](
new ContextConnector(vm, component, providedContextVarieties)
);
}
}

export function disconnectContext(vm: VM) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exposed functions look good. Let's just add docs, and I think they're finished.

Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc May 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not exposed via engine-server/dom - IIUC:

  • ContextConnector (previously, ContextRuntimeAdaptor) is the exposed API
  • connectContext (this function) is called once per component lifecycle, privately by vm.ts here.
  • connectContext purpose is to make any component-associated context providable and consumable (via ContextConnector).
  • I documented ContextConnector and the other API here.
  • I also exported it as a public interface here is the public API?
  • This aligns with prior ContextRuntimeAdaptor

const contextKeys = getContextKeys();

if (!contextKeys) {
return;
}

const { disconnectContext } = contextKeys;
const { component } = vm;

const enumerableKeys = keys(getPrototypeOf(component));
const contextfulKeys = ArrayFilter.call(enumerableKeys, (enumerableKey) =>
isTrustedContext((component as any)[enumerableKey])
);

if (contextfulKeys.length === 0) {
return;
}

for (let i = 0; i < contextfulKeys.length; i++) {
(component as any)[contextfulKeys[i]][disconnectContext](component);
}
}
7 changes: 6 additions & 1 deletion packages/@lwc/engine-core/src/framework/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import type { WireContextSubscriptionPayload } from './wiring';
import type { WireContextSubscriptionCallback, WireContextSubscriptionPayload } from './wiring';

export type HostNode = any;
export type HostElement = any;
Expand Down Expand Up @@ -76,6 +76,11 @@ export interface RendererAPI {
) => E;
defineCustomElement: (tagName: string, isFormAssociated: boolean) => void;
ownerDocument(elm: E): Document;
registerContextProvider: (
element: E,
adapterContextToken: string,
onContextSubscription: WireContextSubscriptionCallback
) => void;
registerContextConsumer: (
element: E,
adapterContextToken: string,
Expand Down
6 changes: 6 additions & 0 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { flushMutationLogsForVM, getAndFlushMutationLogs } from './mutation-logg
import { connectWireAdapters, disconnectWireAdapters, installWireAdapters } from './wiring';
import { VNodeType, isVFragment } from './vnodes';
import { isReportingEnabled, report, ReportingEventId } from './reporting';
import { connectContext, disconnectContext } from './modules/context';
import type { VNodes, VCustomElement, VNode, VBaseElement, VStaticPartElement } from './vnodes';
import type { ReactiveObserver } from './mutation-tracker';
import type {
Expand Down Expand Up @@ -702,6 +703,10 @@ export function runConnectedCallback(vm: VM) {
if (hasWireAdapters(vm)) {
connectWireAdapters(vm);
}

// Setup context before connected callback is executed
connectContext(vm);

const { connectedCallback } = vm.def;
if (!isUndefined(connectedCallback)) {
logOperationStart(OperationId.ConnectedCallback, vm);
Expand Down Expand Up @@ -751,6 +756,7 @@ function runDisconnectedCallback(vm: VM) {
if (process.env.NODE_ENV !== 'production') {
assert.isTrue(vm.state !== VMState.disconnected, `${vm} must be inserted.`);
}
disconnectContext(vm);
if (isFalse(vm.isDirty)) {
// this guarantees that if the component is reused/reinserted,
// it will be re-rendered because we are disconnecting the reactivity
Expand Down
8 changes: 7 additions & 1 deletion packages/@lwc/engine-core/src/framework/wiring/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ export function createContextProviderWithRegister(
consumerDisconnectedCallback(consumer);
}
};
setDisconnectedCallback(disconnectCallback);
setDisconnectedCallback?.(disconnectCallback);

consumerConnectedCallback(consumer);
// Return true as the context is always consumed here and the consumer should
// stop bubbling.
return true;
}
);
};
Expand Down Expand Up @@ -91,6 +94,9 @@ export function createContextWatcher(
// eslint-disable-next-line @lwc/lwc-internal/no-invalid-todo
// TODO: dev-mode validation of config based on the adapter.contextSchema
callbackWhenContextIsReady(newContext);
// Return true as the context is always consumed here and the consumer should
// stop bubbling.
return true;
},
setDisconnectedCallback(disconnectCallback: () => void) {
// adds this callback into the disconnect bucket so it gets disconnected from parent
Expand Down
8 changes: 5 additions & 3 deletions packages/@lwc/engine-core/src/framework/wiring/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ export interface WireDebugInfo {
wasDataProvisionedForConfig: boolean;
}

export type ShouldContinueBubbling = boolean;

export type WireContextSubscriptionCallback = (
subscriptionPayload: WireContextSubscriptionPayload
) => void;
) => ShouldContinueBubbling;

export interface WireContextSubscriptionPayload {
setNewContext(newContext: ContextValue): void;
setDisconnectedCallback(disconnectCallback: () => void): void;
setNewContext(newContext: ContextValue): ShouldContinueBubbling;
setDisconnectedCallback?(disconnectCallback: () => void): void;
}

export interface ContextConsumer {
Expand Down
4 changes: 4 additions & 0 deletions packages/@lwc/engine-core/src/shared/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export function logError(message: string, vm?: VM) {
log('error', message, vm, false);
}

export function logErrorOnce(message: string, vm?: VM) {
log('error', message, vm, true);
}

export function logWarn(message: string, vm?: VM) {
log('warn', message, vm, false);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/@lwc/engine-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export {
isComponentConstructor,
parseFragment,
parseSVGFragment,
setContextKeys,
setTrustedSignalSet,
setTrustedContextSet,
swapComponent,
swapStyle,
swapTemplate,
Expand Down
18 changes: 11 additions & 7 deletions packages/@lwc/engine-dom/src/renderer/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import type {

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

constructor(
adapterToken: string,
Expand Down Expand Up @@ -56,11 +56,15 @@ export function registerContextProvider(
onContextSubscription: WireContextSubscriptionCallback
) {
addEventListener(elm, adapterContextToken, ((evt: WireContextSubscriptionEvent) => {
evt.stopImmediatePropagation();
const { setNewContext, setDisconnectedCallback } = evt;
onContextSubscription({
setNewContext,
setDisconnectedCallback,
});
// If context subscription is successful, stop event propagation
if (
onContextSubscription({
setNewContext,
setDisconnectedCallback,
})
) {
evt.stopImmediatePropagation();
}
}) as EventListener);
}
10 changes: 5 additions & 5 deletions packages/@lwc/engine-server/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function createContextProvider(adapter: WireAdapterConstructor) {
return createContextProviderWithRegister(adapter, registerContextProvider);
}

function registerContextProvider(
export function registerContextProvider(
elm: HostElement | LightningElement,
adapterContextToken: string,
onContextSubscription: WireContextSubscriptionCallback
Expand Down Expand Up @@ -57,10 +57,10 @@ export function registerContextConsumer(
const subscribeToProvider =
currentNode[HostContextProvidersKey].get(adapterContextToken);
if (!isUndefined(subscribeToProvider)) {
subscribeToProvider(subscriptionPayload);
// If we find a provider, we shouldn't continue traversing
// looking for another provider.
break;
// If context subscription is successful, stop traversing to locate a provider
if (subscribeToProvider(subscriptionPayload)) {
break;
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/@lwc/engine-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export {
isComponentConstructor,
parseFragment,
parseFragment as parseSVGFragment,
setTrustedSignalSet,
setTrustedContextSet,
setContextKeys,
} from '@lwc/engine-core';

// Engine-server public APIs -----------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion packages/@lwc/engine-server/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
startTrackingMutations,
stopTrackingMutations,
} from './utils/mutation-tracking';
import { registerContextConsumer } from './context';
import { registerContextConsumer, registerContextProvider } from './context';
import type { HostNode, HostElement, HostAttribute, HostChildNode } from './types';
import type { LifecycleCallback } from '@lwc/engine-core';

Expand Down Expand Up @@ -509,6 +509,7 @@ export const renderer = {
insertStylesheet,
assertInstanceOfHTMLElement,
ownerDocument,
registerContextProvider,
registerContextConsumer,
attachInternals,
defineCustomElement: getUpgradableElement,
Expand Down
Loading