Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
17 changes: 17 additions & 0 deletions packages/@lwc/engine-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,20 @@ This experimental API enables the removal of an object's observable membrane pro
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.

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`.

### setTrustedContextSet()

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
and context has been added to this set, the context object's connectContext and disconnectContext symbols will be called with a ContextConnector when the associated component is connected and disconnected.

If `setTrustedContextSet` is called more than once, it will throw an error. If it is never called, then context will not be connected.

### ContextConnector

The context manager `connectContext` and `disconnectContext` symbols are called with this object when contextful components are connected and disconnected. The ContextConnector exposes `provideContext` and `consumeContext`,
enabling the provision/consumption of a contextful Signal of a specified variety for the associated component.

### setContextKeys

Enables a state manager context implementation to provide LWC with context Symbols, namely `connectContext` and `disconnectContext`. These symbols would then be defined on any context manager implementation, and will be called
with a ContextConnector object when contextful components are connected and disconnected.
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';
152 changes: 152 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,152 @@
/*
* 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,
type ContextProvidedCallback,
type ContextConnector as IContextConnector,
} from '@lwc/shared';
import { type VM } from '../vm';
import { logWarnOnce } from '../../shared/logger';
import type { Signal } from '@lwc/signals';
import type { RendererAPI } from '../renderer';
import type { ShouldContinueBubbling } from '../wiring/types';

type ContextVarieties = Map<unknown, Signal<unknown>>;

class ContextConnector<C extends object> implements IContextConnector<C> {
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)) {
logWarnOnce(
'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();

try {
for (let i = 0; i < contextfulKeys.length; i++) {
(component as any)[contextfulKeys[i]][connectContext](
new ContextConnector(vm, component, providedContextVarieties)
);
}
} catch (err: any) {
logWarnOnce(
`Attempted to connect to trusted context but received the following error: ${
err.message
}`
);
}
}

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;
}

try {
for (let i = 0; i < contextfulKeys.length; i++) {
(component as any)[contextfulKeys[i]][disconnectContext](component);
}
} catch (err: any) {
logWarnOnce(
`Attempted to disconnect from trusted context but received the following error: ${
err.message
}`
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function componentValueObserved(vm: VM, key: PropertyKey, target: any = {
isObject(target) &&
!isNull(target) &&
isTrustedSignal(target) &&
process.env.IS_BROWSER &&
// Only subscribe if a template is being rendered by the engine
tro.isObserving()
) {
Expand Down
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
12 changes: 12 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,12 @@ export function runConnectedCallback(vm: VM) {
if (hasWireAdapters(vm)) {
connectWireAdapters(vm);
}

if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@divmain I protected the new context implementation with the same ENABLE_EXPERIMENTAL_SIGNALS for now, in case rollback is required? Could change to separate gate but I figured the features are closely related?

Copy link
Contributor

Choose a reason for hiding this comment

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

Re-using the flag makes sense to me. They're closely entangled, as you say.

// 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 +758,11 @@ function runDisconnectedCallback(vm: VM) {
if (process.env.NODE_ENV !== 'production') {
assert.isTrue(vm.state !== VMState.disconnected, `${vm} must be inserted.`);
}

if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) {
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
2 changes: 2 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,8 @@ export {
isComponentConstructor,
parseFragment,
parseFragment as parseSVGFragment,
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
Loading