Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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`.

### 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 manager-instantiated context as methods and those methods are called
with a ContextConnector object when contextful components are connected and disconnected.

### 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 state manager-defined context has been added to this set, the context object's connectContext and disconnectContext methods 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 object's `connectContext` and `disconnectContext` methods 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.
35 changes: 26 additions & 9 deletions packages/@lwc/engine-core/src/framework/modules/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ import {
ArrayFilter,
ContextEventName,
isTrustedContext,
type ContextProvidedCallback,
type ContextConnector as IContextConnector,
} from '@lwc/shared';
import { type VM } from '../vm';
import { logErrorOnce } from '../../shared/logger';
import { logWarnOnce } 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> {
class ContextConnector<C extends object> implements IContextConnector<C> {
component: C;
#renderer: RendererAPI;
#providedContextVarieties: ContextVarieties;
Expand Down Expand Up @@ -54,7 +55,7 @@ class ContextConnector<C extends object> {
}

if (this.#providedContextVarieties.has(contextVariety)) {
logErrorOnce(
logWarnOnce(
'Multiple contexts of the same variety were provided. Only the first context will be used.'
);
return;
Expand Down Expand Up @@ -103,9 +104,17 @@ export function connectContext(vm: VM) {

const providedContextVarieties: ContextVarieties = new Map();

for (let i = 0; i < contextfulKeys.length; i++) {
(component as any)[contextfulKeys[i]][connectContext](
new ContextConnector(vm, component, providedContextVarieties)
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
}`
);
}
}
Expand All @@ -129,7 +138,15 @@ export function disconnectContext(vm: VM) {
return;
}

for (let i = 0; i < contextfulKeys.length; i++) {
(component as any)[contextfulKeys[i]][disconnectContext](component);
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
12 changes: 9 additions & 3 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,8 +704,10 @@ export function runConnectedCallback(vm: VM) {
connectWireAdapters(vm);
}

// Setup context before connected callback is executed
connectContext(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)) {
Expand Down Expand Up @@ -756,7 +758,11 @@ function runDisconnectedCallback(vm: VM) {
if (process.env.NODE_ENV !== 'production') {
assert.isTrue(vm.state !== VMState.disconnected, `${vm} must be inserted.`);
}
disconnectContext(vm);

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
1 change: 0 additions & 1 deletion packages/@lwc/engine-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export {
isComponentConstructor,
parseFragment,
parseFragment as parseSVGFragment,
setTrustedSignalSet,
setTrustedContextSet,
setContextKeys,
} from '@lwc/engine-core';
Expand Down
9 changes: 9 additions & 0 deletions packages/@lwc/integration-karma/helpers/test-hydrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,22 @@ window.HydrateTest = (function (lwc, testUtils) {
return div;
}

function setFeatureFlags(requiredFeatureFlags, value) {
if (requiredFeatureFlags) {
requiredFeatureFlags.forEach((featureFlag) => {
lwc.setFeatureFlagForTest(featureFlag, value);
});
}
}

function runTest(ssrRendered, Component, testConfig) {
const container = appendTestTarget(ssrRendered);
const selector = container.firstChild.tagName.toLowerCase();
let target = container.querySelector(selector);

let testResult;
const consoleSpy = testUtils.spyConsole();
setFeatureFlags(testConfig.requiredFeatureFlags, true);

if (testConfig.test) {
const snapshot = testConfig.snapshot ? testConfig.snapshot(target) : {};
Expand Down
5 changes: 5 additions & 0 deletions packages/@lwc/integration-karma/helpers/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,10 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) {
}
}

function setFeatureFlagForTest(featureFlag, value) {
LWC.setFeatureFlagForTest(featureFlag, value);
}

// This mapping should be kept up-to-date with the mapping in @lwc/shared -> aria.ts
const ariaPropertiesMapping = {
ariaAutoComplete: 'aria-autocomplete',
Expand Down Expand Up @@ -747,6 +751,7 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) {
catchUnhandledRejectionsAndErrors,
addTrustedSignal,
expectEquivalentDOM,
setFeatureFlagForTest,
...apiFeatures,
};
})(LWC, jasmine, beforeAll);
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ const fs = require('node:fs/promises');
const { format } = require('node:util');
const { rollup } = require('rollup');
const lwcRollupPlugin = require('@lwc/rollup-plugin');
const ssr = ENGINE_SERVER ? require('@lwc/engine-server') : require('@lwc/ssr-runtime');
const lwcSsr = ENGINE_SERVER ? require('@lwc/engine-server') : require('@lwc/ssr-runtime');
const { DISABLE_STATIC_CONTENT_OPTIMIZATION } = require('../shared/options');
const Watcher = require('./Watcher');

const context = {
LWC: ssr,
LWC: lwcSsr,
moduleOutput: null,
};

ssr.setHooks({
lwcSsr.setHooks({
sanitizeHtmlContent(content) {
return content;
},
Expand Down Expand Up @@ -145,14 +145,7 @@ function throwOnUnexpectedConsoleCalls(runnable, expectedConsoleCalls = {}) {
* So, script runs, generates markup, & we get that markup out and return it to Karma for use
* in client-side tests.
*/
async function getSsrCode(moduleCode, testConfig, filename) {
// Create a temporary module to evaluate the bundled code and extract the expected console calls
const configModule = new vm.Script(testConfig);
const configContext = { config: {} };
vm.createContext(configContext);
configModule.runInContext(configContext);
const { expectedSSRConsoleCalls } = configContext.config;

async function getSsrCode(moduleCode, testConfig, filename, expectedSSRConsoleCalls) {
const script = new vm.Script(
`
${testConfig};
Expand Down Expand Up @@ -221,10 +214,22 @@ function createHCONFIG2JSPreprocessor(config, logger, emitter) {
// Wrap all the tests into a describe block with the file stricture name
const describeTitle = path.relative(basePath, suiteDir).split(path.sep).join(' ');

try {
const { code: testCode, watchFiles: testWatchFiles } =
await getTestModuleCode(filePath);
const { code: testCode, watchFiles: testWatchFiles } = await getTestModuleCode(filePath);

// Create a temporary module to evaluate the bundled code and extract config properties for test configuration
const configModule = new vm.Script(testCode);
const configContext = { config: {} };
vm.createContext(configContext);
configModule.runInContext(configContext);
const { expectedSSRConsoleCalls, requiredFeatureFlags } = configContext.config;

if (requiredFeatureFlags) {
requiredFeatureFlags.forEach((featureFlag) => {
lwcSsr.setFeatureFlagForTest(featureFlag, true);
});
}

try {
// You can add an `.only` file alongside an `index.spec.js` file to make it `fdescribe()`
const onlyFileExists = await existsUp(suiteDir, '.only');

Expand All @@ -241,7 +246,8 @@ function createHCONFIG2JSPreprocessor(config, logger, emitter) {
ssrOutput = await getSsrCode(
componentDefCSR,
testCode,
path.join(suiteDir, 'ssr.js')
path.join(suiteDir, 'ssr.js'),
expectedSSRConsoleCalls
);
} else {
// ssr-compiler has it's own def
Expand All @@ -254,7 +260,8 @@ function createHCONFIG2JSPreprocessor(config, logger, emitter) {
ssrOutput = await getSsrCode(
componentDefSSR.replace(`process.env.NODE_ENV === 'test-karma-lwc'`, 'true'),
testCode,
path.join(suiteDir, 'ssr.js')
path.join(suiteDir, 'ssr.js'),
expectedSSRConsoleCalls
);
}

Expand All @@ -271,6 +278,12 @@ function createHCONFIG2JSPreprocessor(config, logger, emitter) {
const location = path.relative(basePath, filePath);
log.error('Error processing “%s”\n\n%s\n', location, error.stack || error.message);
done(error, null);
} finally {
if (requiredFeatureFlags) {
requiredFeatureFlags.forEach((featureFlag) => {
lwcSsr.setFeatureFlagForTest(featureFlag, false);
});
}
}
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
export default {
// server is expected to generate the same console error as the client
expectedSSRConsoleCalls: {
error: [
error: [],
warn: [
'Attempted to connect to trusted context but received the following error',
'Multiple contexts of the same variety were provided. Only the first context will be used.',
],
warn: [],
},
requiredFeatureFlags: ['ENABLE_EXPERIMENTAL_SIGNALS'],
snapshot(target) {
const grandparent = target.shadowRoot.querySelector('x-grandparent');
const detachedChild = target.shadowRoot.querySelector('x-child');
Expand Down Expand Up @@ -36,12 +38,14 @@ export default {
assertContextDisconnected(target, snapshot);

// Expect an error as one context was generated twice.
// Expect an error as one context was malformed (did not define connectContext or disconnectContext methods).
// Expect server/client context output parity (no hydration warnings)
TestUtils.expectConsoleCalls(consoleCalls, {
error: [
error: [],
warn: [
'Attempted to connect to trusted context but received the following error',
'Multiple contexts of the same variety were provided. Only the first context will be used.',
],
warn: [],
});
},
};
Expand All @@ -52,7 +56,7 @@ function assertCorrectContext(snapshot) {
.withContext(`${component.tagName} should have the correct context`)
.toBe('grandparent provided value, another grandparent provided value');

expect(component.context.connectProvidedComponent.hostElement)
expect(component.context.connectProvidedComponent?.hostElement)
.withContext(
`The context of ${component.tagName} should have been connected with the correct component`
)
Expand Down Expand Up @@ -86,7 +90,7 @@ function assertContextDisconnected(target, snapshot) {
Object.values(snapshot.components).forEach(
(component) =>
(component.disconnect = () => {
expect(component.context.disconnectProvidedComponent.hostElement)
expect(component.context.disconnectProvidedComponent?.hostElement)
.withContext(
`The context of ${component.tagName} should have been disconnected with the correct component`
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,19 @@ class MockContextSignal {
}
}

// This is a malformed context signal that does not implement the connectContext or disconnectContext methods
class MockMalformedContextSignal {
constructor() {
trustedContext.add(this);
}
}

export const defineContext = (fromContext) => {
const contextDefinition = (initialValue) =>
new MockContextSignal(initialValue, contextDefinition, fromContext);
return contextDefinition;
};

export const defineMalformedContext = () => {
return () => new MockMalformedContextSignal();
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { LightningElement, api } from 'lwc';
import { defineMalformedContext } from 'x/contextManager';
export default class Root extends LightningElement {
@api showTree = false;
malformedContext = defineMalformedContext()();

connectedCallback() {
this.showTree = true;
Expand Down
13 changes: 13 additions & 0 deletions packages/@lwc/shared/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ export type ContextKeys = {
disconnectContext: symbol;
};

export type ContextProvidedCallback = (contextSignal?: object) => void;

export interface ContextConnector<C extends object> {
component: C;

provideContext<V extends object>(contextVariety: V, providedContextSignal: object): void;

consumeContext<V extends object>(
contextVariety: V,
contextProvidedCallback: ContextProvidedCallback
): void;
}

let contextKeys: ContextKeys;

export function setContextKeys(config: ContextKeys) {
Expand Down
3 changes: 0 additions & 3 deletions packages/@lwc/ssr-runtime/src/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,6 @@ export function setContextKeys(..._: unknown[]): never {
export function setTrustedContextSet(..._: unknown[]): never {
throw new Error('setTrustedContextSet cannot be used in SSR context.');
}
export function setTrustedSignalSet(..._: unknown[]): never {
throw new Error('@setTrustedSignalSet cannot be used in SSR context.');
}

export const renderer = {
isSyntheticShadowDefined: false,
Expand Down
3 changes: 1 addition & 2 deletions packages/lwc/__tests__/isomorphic-exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,16 @@ describe('isomorphic package exports', () => {
'hydrateComponent',
'isNodeFromTemplate',
'rendererFactory',
'setTrustedSignalSet',
]);
});

test('ssr-runtime is a superset of engine-server', () => {
const baseExports = new Set(Object.keys(engineServer));
const superExports = new Set(Object.keys(ssrRuntime));

for (const exp of superExports) {
baseExports.delete(exp);
}

expect(Array.from(baseExports)).toEqual([
// Exports that intentionally only exist in @lwc/engine-server
'default', // artifact of interop support
Expand Down
Loading