Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bake context into lwc framework #4995

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 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
23 changes: 23 additions & 0 deletions packages/@lwc/engine-core/src/framework/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2024, 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 { ContextEventName, getContextKeys } from '@lwc/shared';
import type { Signal } from '@lwc/signals';

export type ContextProvidedCallback = (contextSignal: Signal<unknown>) => void;
export class ContextRequestEvent extends CustomEvent<{
key: symbol;
contextVariety: unknown;
callback: ContextProvidedCallback;
}> {
constructor(detail: { contextVariety: unknown; callback: ContextProvidedCallback }) {
super(ContextEventName, {
bubbles: true,
composed: true,
detail: { ...detail, key: getContextKeys().contextEventKey },
});
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Have you looked at the Web Components Community Group proposal for a "context" event? This is an attempt to standardize the concept of passing context between ancestor/descendant components across web component frameworks.

The advantage of using this protocol is that, hypothetically, a non-LWC framework like Lit could use the same protocol and be interoperable with LWC in the same DOM tree.

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 @@ -72,5 +72,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 } from '@lwc/shared';
export type { Stylesheet, Stylesheets } from '@lwc/shared';
110 changes: 110 additions & 0 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
assert,
create,
defineProperty,
getPrototypeOf,
getOwnPropertyNames,
keys,
isArray,
isFalse,
isFunction,
Expand All @@ -20,6 +22,8 @@ import {
isTrue,
isUndefined,
flattenStylesheets,
getContextKeys,
ContextEventName,
} from '@lwc/shared';

import { addErrorComponentStack } from '../shared/error';
Expand Down Expand Up @@ -49,6 +53,8 @@ import { flushMutationLogsForVM, getAndFlushMutationLogs } from './mutation-logg
import { connectWireAdapters, disconnectWireAdapters, installWireAdapters } from './wiring';
import { VNodeType, isVFragment } from './vnodes';
import { isReportingEnabled, report, ReportingEventId } from './reporting';
import { type ContextProvidedCallback, ContextRequestEvent } from './context';

import type { VNodes, VCustomElement, VNode, VBaseElement, VStaticPartElement } from './vnodes';
import type { ReactiveObserver } from './mutation-tracker';
import type {
Expand All @@ -60,6 +66,7 @@ import type { ComponentDef } from './def';
import type { Template } from './template';
import type { HostNode, HostElement, RendererAPI } from './renderer';
import type { Stylesheet, Stylesheets, APIVersion } from '@lwc/shared';
import type { Signal } from '@lwc/signals';

type ShadowRootMode = 'open' | 'closed';

Expand Down Expand Up @@ -699,6 +706,9 @@ export function runConnectedCallback(vm: VM) {
if (hasWireAdapters(vm)) {
connectWireAdapters(vm);
}
// Setup context before connected callback is executed
setupContext(vm);

const { connectedCallback } = vm.def;
if (!isUndefined(connectedCallback)) {
logOperationStart(OperationId.ConnectedCallback, vm);
Expand Down Expand Up @@ -740,10 +750,109 @@ export function runConnectedCallback(vm: VM) {
}
}

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

if (!contextKeys) {
rax-it marked this conversation as resolved.
Show resolved Hide resolved
return;
}

const { connectContext, contextEventKey } = contextKeys;
const { component } = vm;
const enumerableKeys = keys(getPrototypeOf(component));
const contextfulFieldsOrProps = enumerableKeys.filter(
rax-it marked this conversation as resolved.
Show resolved Hide resolved
(propName) => (component as any)[propName]?.[connectContext]
);

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

let isProvidingContext = false;
const providedContextVarieties = new Map<unknown, Signal<unknown>>();
const contextRuntimeAdapter = {
isServerSide: false,
Copy link
Collaborator

Choose a reason for hiding this comment

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

engine-core may execute in a server context, in which case process.env.IS_BROWSER will be false. Does that help here?

component,
provideContext<T extends object>(
contextVariety: T,
providedContextSignal: Signal<unknown>
): void {
if (!isProvidingContext) {
isProvidingContext = true;

component.addEventListener(ContextEventName, (event: any) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We typically use the renderer API for this. Although it's unclear to me how this would work in an SSR context since we don't implement an event listener shim. For the ContextProvider API we implemented a full SSR-compatible shim to make it work (#3165); presumably we ought to do the same here if this needs to run server-side.

if (
event.detail.key === contextEventKey &&
providedContextVarieties.has(event.detail.contextVariety)
) {
event.stopImmediatePropagation();
const providedContextSignal = providedContextVarieties.get(
event.detail.contextVariety
);
event.detail.callback(providedContextSignal);
}
});
}

let multipleContextWarningShown = false;

if (providedContextVarieties.has(contextVariety)) {
if (!multipleContextWarningShown) {
multipleContextWarningShown = true;
logError(
'Multiple contexts of the same variety were provided. Only the first context will be used.'
);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This code is not covered in the Karma tests. You can download the coverage report and see that actually quite a bit is uncovered.

Screenshot 2024-12-10 at 9 59 01 AM

return;
}

providedContextVarieties.set(contextVariety, providedContextSignal);
},
consumeContext<T extends object>(
contextVariety: T,
contextProvidedCallback: ContextProvidedCallback
): void {
const event = new ContextRequestEvent({
contextVariety,
callback: contextProvidedCallback,
});

component.dispatchEvent(event);
},
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

This may perform better in JS engines if it's an explicit class that we are newing.


for (const contextfulFieldsOrProp of contextfulFieldsOrProps) {
(component as any)[contextfulFieldsOrProp][connectContext](contextRuntimeAdapter);
}
}

function hasWireAdapters(vm: VM): boolean {
return getOwnPropertyNames(vm.def.wire).length > 0;
}

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

if (!contextKeys) {
return;
}

const { disconnectContext } = contextKeys;
const { component } = vm;
const enumerableKeys = keys(getPrototypeOf(component));
const contextfulFieldsOrProps = enumerableKeys.filter(
(propName) => (component as any)[propName]?.[disconnectContext]
);

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

for (const contextfulField of contextfulFieldsOrProps) {
(component as any)[contextfulField][disconnectContext](component);
}
}

function runDisconnectedCallback(vm: VM) {
if (process.env.NODE_ENV !== 'production') {
assert.isTrue(vm.state !== VMState.disconnected, `${vm} must be inserted.`);
Expand All @@ -767,6 +876,7 @@ function runDisconnectedCallback(vm: VM) {

logOperationEnd(OperationId.DisconnectedCallback, vm);
}
cleanupContext(vm);
}

function runChildNodesDisconnectedCallback(vm: VM) {
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/engine-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
isComponentConstructor,
parseFragment,
parseSVGFragment,
setContextKeys,
setTrustedSignalSet,
swapComponent,
swapStyle,
Expand Down
13 changes: 13 additions & 0 deletions packages/@lwc/integration-karma/helpers/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,18 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) {
signalValidator.add(signal);
}

const contextKeys = {
connectContext: Symbol('connectContext'),
disconnectContext: Symbol('disconnectContext'),
contextEventKey: Symbol('contextEventKey'),
};

lwc.setContextKeys(contextKeys);

function getContextKeys() {
return contextKeys;
}

return {
clearRegister,
extractDataIds,
Expand Down Expand Up @@ -736,6 +748,7 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) {
catchUnhandledRejectionsAndErrors,
addTrustedSignal,
expectEquivalentDOM,
getContextKeys,
...apiFeatures,
};
})(LWC, jasmine, beforeAll);
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { customElementCallbackReactionErrorListener } from 'test-utils';
import Test from 'x/test';
import ConnectedCallbackThrow from 'x/connectedCallbackThrow';
import XSlottedParent from 'x/slottedParent';
import ContextParent from 'x/contextParent';

function testConnectSlot(name, fn) {
it(`should invoke the connectedCallback the root element is added in the DOM via ${name}`, () => {
Expand Down Expand Up @@ -63,3 +64,14 @@ describe('addEventListner in `connectedCallback`', () => {
});
});
});

describe('context', () => {
it('connects contextful fields when running connectedCallback', () => {
const elm = createElement('x-context-parent', { is: ContextParent });
document.body.appendChild(elm);
const child = elm.shadowRoot.querySelector('x-context-child');

expect(child).toBeDefined();
expect(child.shadowRoot.querySelector('p').textContent).toBe('Test Child: foo');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<p>Test Child: {name}</p>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { LightningElement } from 'lwc';
import { consumeStateFactory } from 'x/state';

export default class TestChildSymbol extends LightningElement {
randomChild = consumeStateFactory();

get name() {
return this.randomChild.value.context?.value?.name ?? 'not available';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<x-context-child>
</x-context-child>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { LightningElement } from 'lwc';
import { nameStateFactory } from 'x/state';

export default class ContextParent extends LightningElement {
random = nameStateFactory();
}
Loading