diff --git a/packages/@lwc/engine-core/src/framework/context.ts b/packages/@lwc/engine-core/src/framework/context.ts new file mode 100644 index 0000000000..207e1b4f02 --- /dev/null +++ b/packages/@lwc/engine-core/src/framework/context.ts @@ -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) => 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 }, + }); + } +} diff --git a/packages/@lwc/engine-core/src/framework/main.ts b/packages/@lwc/engine-core/src/framework/main.ts index c7ecba9c99..1d4903758e 100644 --- a/packages/@lwc/engine-core/src/framework/main.ts +++ b/packages/@lwc/engine-core/src/framework/main.ts @@ -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'; diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index a309c71c91..50d2e65810 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -5,13 +5,16 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import { + ArrayFilter, ArrayPush, ArraySlice, ArrayUnshift, assert, create, defineProperty, + getPrototypeOf, getOwnPropertyNames, + keys, isArray, isFalse, isFunction, @@ -20,6 +23,8 @@ import { isTrue, isUndefined, flattenStylesheets, + getContextKeys, + ContextEventName, } from '@lwc/shared'; import { addErrorComponentStack } from '../shared/error'; @@ -49,6 +54,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 { @@ -60,6 +67,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'; @@ -699,6 +707,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); @@ -740,10 +751,110 @@ export function runConnectedCallback(vm: VM) { } } +function setupContext(vm: VM) { + const contextKeys = getContextKeys(); + + if (isUndefined(contextKeys)) { + return; + } + + const { connectContext, contextEventKey } = contextKeys; + const { component, renderer } = vm; + const enumerableKeys = keys(getPrototypeOf(component)); + const contextfulFieldsOrProps = ArrayFilter.call( + enumerableKeys, + (propName) => (component as any)[propName]?.[connectContext] + ); + + if (contextfulFieldsOrProps.length === 0) { + return; + } + + let isProvidingContext = false; + const providedContextVarieties = new Map>(); + const contextRuntimeAdapter = { + isServerSide: false, + component, + provideContext( + contextVariety: T, + providedContextSignal: Signal + ): void { + if (!isProvidingContext) { + isProvidingContext = true; + + renderer.addEventListener(component, ContextEventName, (event: any) => { + 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.' + ); + } + return; + } + + providedContextVarieties.set(contextVariety, providedContextSignal); + }, + consumeContext( + contextVariety: T, + contextProvidedCallback: ContextProvidedCallback + ): void { + const event = new ContextRequestEvent({ + contextVariety, + callback: contextProvidedCallback, + }); + + renderer.dispatchEvent(component, event); + }, + }; + + 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.`); @@ -767,6 +878,7 @@ function runDisconnectedCallback(vm: VM) { logOperationEnd(OperationId.DisconnectedCallback, vm); } + cleanupContext(vm); } function runChildNodesDisconnectedCallback(vm: VM) { diff --git a/packages/@lwc/engine-dom/src/index.ts b/packages/@lwc/engine-dom/src/index.ts index 1add267202..d3213fc5a6 100644 --- a/packages/@lwc/engine-dom/src/index.ts +++ b/packages/@lwc/engine-dom/src/index.ts @@ -30,6 +30,7 @@ export { isComponentConstructor, parseFragment, parseSVGFragment, + setContextKeys, setTrustedSignalSet, swapComponent, swapStyle, diff --git a/packages/@lwc/integration-karma/helpers/test-state.js b/packages/@lwc/integration-karma/helpers/test-state.js new file mode 100644 index 0000000000..7ffaa495ff --- /dev/null +++ b/packages/@lwc/integration-karma/helpers/test-state.js @@ -0,0 +1,261 @@ +window.TestState = (function (lwc, testUtils) { + const connectContext = Symbol('connectContext'); + const disconnectContext = Symbol('disconnectContext'); + const contextEventKey = Symbol('contextEventKey'); + + const contextKeys = { + connectContext, + disconnectContext, + contextEventKey, + }; + + lwc.setContextKeys(contextKeys); + + var SignalBaseClass = class { + constructor() { + this.subscribers = /* @__PURE__ */ new Set(); + testUtils.addTrustedSignal(this); + } + subscribe(onUpdate) { + this.subscribers.add(onUpdate); + return () => { + this.subscribers.delete(onUpdate); + }; + } + notify() { + for (const subscriber of this.subscribers) { + subscriber(); + } + } + }; + + // src/index.ts + var atomSetter = Symbol('atomSetter'); + var contextID = Symbol('contextID'); + var AtomSignal = class extends SignalBaseClass { + constructor(value) { + super(); + this._value = value; + } + [atomSetter](value) { + this._value = value; + this.notify(); + } + get value() { + return this._value; + } + }; + var ContextAtomSignal = class extends AtomSignal { + constructor() { + super(...arguments); + this._id = contextID; + } + }; + var ComputedSignal = class extends SignalBaseClass { + constructor(inputSignalsObj, computer) { + super(); + this.isStale = true; + this.computer = computer; + this.dependencies = inputSignalsObj; + const onUpdate = () => { + this.isStale = true; + this.notify(); + }; + for (const signal of Object.values(inputSignalsObj)) { + signal.subscribe(onUpdate); + } + } + computeValue() { + const dependencyValues = {}; + for (const [signalName, signal] of Object.entries(this.dependencies)) { + dependencyValues[signalName] = signal.value; + } + this.isStale = false; + this._value = this.computer(dependencyValues); + } + notify() { + this.isStale = true; + super.notify(); + } + get value() { + if (this.isStale) { + this.computeValue(); + } + return this._value; + } + }; + var isUpdater = (signalOrUpdater) => typeof signalOrUpdater === 'function'; + var atom = (initialValue) => new AtomSignal(initialValue); + var computed = (inputSignalsObj, computer) => new ComputedSignal(inputSignalsObj, computer); + var update = (signalsToUpdate, userProvidedUpdaterFn) => { + return (...uniqueArgs) => { + const signalValues = {}; + for (const [signalName, signal] of Object.entries(signalsToUpdate)) { + signalValues[signalName] = signal.value; + } + const newValues = userProvidedUpdaterFn(signalValues, ...uniqueArgs); + for (const [atomName, newValue] of Object.entries(newValues)) { + signalsToUpdate[atomName][atomSetter](newValue); + } + }; + }; + var defineState = (defineStateCallback) => { + const stateDefinition = (...args) => { + class StateManagerSignal extends SignalBaseClass { + constructor() { + super(); + this.isStale = true; + this.isNotifyScheduled = false; + // biome-ignore lint/suspicious/noExplicitAny: we actually do want this, thanks + this.contextSignals = /* @__PURE__ */ new Map(); + this.contextConsumptionQueue = []; + this.contextUnsubscribes = /* @__PURE__ */ new WeakMap(); + const fromContext2 = (contextVarietyUniqueId) => { + if (this.contextSignals.has(contextVarietyUniqueId)) { + return this.contextSignals.get(contextVarietyUniqueId); + } + const localContextSignal = new ContextAtomSignal(void 0); + this.contextSignals.set(contextVarietyUniqueId, localContextSignal); + this.contextConsumptionQueue.push((runtimeAdapter) => { + if (!runtimeAdapter) { + throw new Error( + 'Implementation error: runtimeAdapter must be present at the time of connect.' + ); + } + runtimeAdapter.consumeContext( + contextVarietyUniqueId, + (providedContextSignal) => { + localContextSignal[atomSetter](providedContextSignal.value); + const unsub = providedContextSignal.subscribe(() => { + localContextSignal[atomSetter](providedContextSignal.value); + }); + if (!this.contextUnsubscribes.has(runtimeAdapter.component)) { + this.contextUnsubscribes.set(runtimeAdapter.component, []); + } + this.contextUnsubscribes + .get(runtimeAdapter.component) + .push(unsub); + } + ); + }); + return localContextSignal; + }; + this.internalStateShape = defineStateCallback( + atom, + computed, + update, + fromContext2 + )(...args); + for (const signalOrUpdater of Object.values(this.internalStateShape)) { + if (signalOrUpdater && !isUpdater(signalOrUpdater)) { + signalOrUpdater.subscribe(this.scheduledNotify.bind(this)); + } + } + } + [connectContext](runtimeAdapter) { + runtimeAdapter.provideContext(stateDefinition, this); + for (const connectContext2 of this.contextConsumptionQueue) { + connectContext2(runtimeAdapter); + } + } + [disconnectContext](componentId) { + const unsubArray = this.contextUnsubscribes.get(componentId); + if (!unsubArray) { + return; + } + while (unsubArray.length !== 0) { + unsubArray.pop()(); + } + } + shareableContext() { + const contextAtom = new ContextAtomSignal(void 0); + const updateContextAtom = () => { + const valueWithUpdaters = this.value; + const filteredValue = Object.fromEntries( + Object.entries(valueWithUpdaters).filter( + ([, valueOrUpdater]) => !isUpdater(valueOrUpdater) + ) + ); + contextAtom[atomSetter](Object.freeze(filteredValue)); + }; + updateContextAtom(); + this.subscribe(updateContextAtom); + return contextAtom; + } + computeValue() { + const computedValue = Object.fromEntries( + Object.entries(this.internalStateShape) + .filter(([, signalOrUpdater]) => signalOrUpdater) + .map(([key, signalOrUpdater]) => { + if ( + isUpdater(signalOrUpdater) || + signalOrUpdater._id === contextID + ) { + return [key, signalOrUpdater]; + } + return [key, signalOrUpdater.value]; + }) + ); + this._value = Object.freeze(computedValue); + this.isStale = false; + } + scheduledNotify() { + this.isStale = true; + if (!this.isNotifyScheduled) { + queueMicrotask(() => { + this.isNotifyScheduled = false; + super.notify(); + }); + this.isNotifyScheduled = true; + } + } + get value() { + if (this.isStale) { + this.computeValue(); + } + return this._value; + } + } + return new StateManagerSignal(); + }; + return stateDefinition; + }; + + const nameStateFactory = defineState((atom, computed, update) => (initialName = 'foo') => { + const name = atom(initialName); + + const updateName = update({ name }, (_, newName) => ({ + name: newName, + })); + + return { + name, + updateName, + }; + }); + + const consumeStateFactory = defineState( + (atom, computed, update, fromContext) => + (initialName = 'bar') => { + const name = atom(initialName); + const context = fromContext(nameStateFactory); + + const updateName = update({ name }, (_, newName) => ({ + name: newName, + })); + + return { + name, + updateName, + context, + }; + } + ); + + return { + defineState, + nameStateFactory, + consumeStateFactory, + }; + /* @lwc/state v0.4.2 */ +})(window.LWC, window.TestUtils); diff --git a/packages/@lwc/integration-karma/scripts/karma-configs/hydration/base.js b/packages/@lwc/integration-karma/scripts/karma-configs/hydration/base.js index b4316eb0cc..2aab421524 100644 --- a/packages/@lwc/integration-karma/scripts/karma-configs/hydration/base.js +++ b/packages/@lwc/integration-karma/scripts/karma-configs/hydration/base.js @@ -27,6 +27,7 @@ const LWC_ENGINE = require.resolve('@lwc/engine-dom/dist/index.js'); const TEST_UTILS = require.resolve('../../../helpers/test-utils'); const TEST_SETUP = require.resolve('../../../helpers/test-setup'); const TEST_HYDRATE = require.resolve('../../../helpers/test-hydrate'); +const TEST_STATE = require.resolve('../../../helpers/test-state'); const ALL_FRAMEWORK_FILES = [SYNTHETIC_SHADOW, LWC_ENGINE]; @@ -43,6 +44,7 @@ function getFiles() { createPattern(TEST_SETUP), createPattern(TEST_UTILS), createPattern(TEST_HYDRATE), + createPattern(TEST_STATE), ]; // check if a .only file exists diff --git a/packages/@lwc/integration-karma/scripts/karma-configs/test/base.js b/packages/@lwc/integration-karma/scripts/karma-configs/test/base.js index c42a76fcb9..d7b469b723 100644 --- a/packages/@lwc/integration-karma/scripts/karma-configs/test/base.js +++ b/packages/@lwc/integration-karma/scripts/karma-configs/test/base.js @@ -31,6 +31,7 @@ const ARIA_REFLECTION = require.resolve('@lwc/aria-reflection/dist/index.js'); const TEST_UTILS = require.resolve('../../../helpers/test-utils'); const TEST_SETUP = require.resolve('../../../helpers/test-setup'); +const TEST_STATE = require.resolve('../../../helpers/test-state'); const ALL_FRAMEWORK_FILES = [SYNTHETIC_SHADOW, LWC_ENGINE, WIRE_SERVICE, ARIA_REFLECTION]; @@ -56,6 +57,7 @@ function getFiles() { return [ ...frameworkFiles, createPattern(TEST_UTILS), + createPattern(TEST_STATE), createPattern('**/*.spec.js', { watched: false }), ]; } diff --git a/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js b/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js index 59c13b5f71..e06b2b3497 100644 --- a/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js +++ b/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js @@ -100,7 +100,7 @@ function createPreprocessor(config, emitter, logger) { // Rollup should not attempt to resolve the engine and the test utils, Karma takes care of injecting it // globally in the page before running the tests. - external: ['lwc', 'wire-service', 'test-utils', '@test/loader'], + external: ['lwc', 'wire-service', 'test-utils', '@test/loader', 'test-state'], onwarn(warning, warn) { // Ignore warnings from our own Rollup plugin @@ -125,6 +125,7 @@ function createPreprocessor(config, emitter, logger) { lwc: 'LWC', 'wire-service': 'WireService', 'test-utils': 'TestUtils', + 'test-state': 'TestState', }, intro, diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/index.spec.js b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/index.spec.js index f684e9cc01..81fa174438 100644 --- a/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/index.spec.js +++ b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/index.spec.js @@ -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}`, () => { @@ -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'); + }); +}); diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextChild/contextChild.html b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextChild/contextChild.html new file mode 100644 index 0000000000..6ed6f4f29b --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextChild/contextChild.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextChild/contextChild.js b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextChild/contextChild.js new file mode 100644 index 0000000000..29a7e25f9a --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextChild/contextChild.js @@ -0,0 +1,10 @@ +import { LightningElement } from 'lwc'; +import { consumeStateFactory } from 'test-state'; + +export default class TestChildSymbol extends LightningElement { + randomChild = consumeStateFactory(); + + get name() { + return this.randomChild.value.context?.value?.name ?? 'not available'; + } +} diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextParent/contextParent.html b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextParent/contextParent.html new file mode 100644 index 0000000000..d4a51fe57a --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextParent/contextParent.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextParent/contextParent.js b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextParent/contextParent.js new file mode 100644 index 0000000000..d80c68baac --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.connectedCallback/x/contextParent/contextParent.js @@ -0,0 +1,6 @@ +import { LightningElement } from 'lwc'; +import { nameStateFactory } from 'test-state'; + +export default class ContextParent extends LightningElement { + random = nameStateFactory(); +} diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/index.spec.js b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/index.spec.js index 765c954e46..50b933d557 100644 --- a/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/index.spec.js +++ b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/index.spec.js @@ -6,6 +6,8 @@ import Test from 'x/test'; import DisconnectedCallbackThrow from 'x/disconnectedCallbackThrow'; import DualTemplate from 'x/dualTemplate'; import ExplicitRender from 'x/explicitRender'; +import ContextParent from 'x/contextParent'; +import { nameStateFactory } from 'test-state'; function testDisconnectSlot(name, fn) { it(`should invoke the disconnectedCallback when root element is removed from the DOM via ${name}`, () => { @@ -189,3 +191,17 @@ describe('disconnectedCallback for components with a explicit render()', () => { }); }); }); + +describe('context', () => { + it('removing child unsubscribes from context subscription during disconnect', async () => { + const elm = createElement('x-context-parent', { is: ContextParent }); + const state = nameStateFactory(); + elm.state = state; + document.body.appendChild(elm); + + expect(state.subscribers.size).toBe(1); + elm.hideChild = true; + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(state.subscribers.size).toBe(0); + }); +}); diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextChild/contextChild.html b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextChild/contextChild.html new file mode 100644 index 0000000000..7051f978f8 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextChild/contextChild.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextChild/contextChild.js b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextChild/contextChild.js new file mode 100644 index 0000000000..29a7e25f9a --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextChild/contextChild.js @@ -0,0 +1,10 @@ +import { LightningElement } from 'lwc'; +import { consumeStateFactory } from 'test-state'; + +export default class TestChildSymbol extends LightningElement { + randomChild = consumeStateFactory(); + + get name() { + return this.randomChild.value.context?.value?.name ?? 'not available'; + } +} diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextParent/contextParent.html b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextParent/contextParent.html new file mode 100644 index 0000000000..f1aaa7ebd3 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextParent/contextParent.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextParent/contextParent.js b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextParent/contextParent.js new file mode 100644 index 0000000000..64a5ebbbc5 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.disconnectedCallback/x/contextParent/contextParent.js @@ -0,0 +1,13 @@ +import { LightningElement, api } from 'lwc'; + +export default class ContextParent extends LightningElement { + @api + state; + + @api + hideChild = false; + + get showChild() { + return !this.hideChild; + } +} diff --git a/packages/@lwc/shared/src/__tests__/context.spec.ts b/packages/@lwc/shared/src/__tests__/context.spec.ts new file mode 100644 index 0000000000..01b73d5286 --- /dev/null +++ b/packages/@lwc/shared/src/__tests__/context.spec.ts @@ -0,0 +1,60 @@ +/* + * 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 { describe, beforeEach, expect, it, vi } from 'vitest'; + +describe('context', () => { + let setContextKeys: (config: any) => void; + let getContextKeys: () => any; + + beforeEach(async () => { + vi.resetModules(); + const contextModule = await import('../context'); + setContextKeys = contextModule.setContextKeys; + getContextKeys = contextModule.getContextKeys; + }); + + it('should set and get context keys', () => { + const mockContextKeys = { + connectContext: Symbol('connect'), + disconnectContext: Symbol('disconnect'), + contextEventKey: Symbol('event'), + }; + + setContextKeys(mockContextKeys); + const retrievedKeys = getContextKeys(); + + expect(retrievedKeys).toBe(mockContextKeys); + expect(retrievedKeys.connectContext).toBe(mockContextKeys.connectContext); + expect(retrievedKeys.disconnectContext).toBe(mockContextKeys.disconnectContext); + expect(retrievedKeys.contextEventKey).toBe(mockContextKeys.contextEventKey); + }); + + it('should throw when attempting to set context keys multiple times', () => { + const mockContextKeys1 = { + connectContext: Symbol('connect1'), + disconnectContext: Symbol('disconnect1'), + contextEventKey: Symbol('event1'), + }; + + const mockContextKeys2 = { + connectContext: Symbol('connect2'), + disconnectContext: Symbol('disconnect2'), + contextEventKey: Symbol('event2'), + }; + + setContextKeys(mockContextKeys1); + + expect(() => { + setContextKeys(mockContextKeys2); + }).toThrow('`setContextKeys` cannot be called more than once'); + }); + + it('should return undefined when getting context keys before setting them', () => { + const keys = getContextKeys(); + expect(keys).toBeUndefined(); + }); +}); diff --git a/packages/@lwc/shared/src/context.ts b/packages/@lwc/shared/src/context.ts new file mode 100644 index 0000000000..492b0ed46e --- /dev/null +++ b/packages/@lwc/shared/src/context.ts @@ -0,0 +1,27 @@ +/* + * 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 { isFalse } from './assert'; + +export type ContextKeys = { + connectContext: symbol; + disconnectContext: symbol; + contextEventKey: symbol; +}; + +let contextKeys: ContextKeys; + +export function setContextKeys(config: ContextKeys) { + isFalse(contextKeys, '`setContextKeys` cannot be called more than once'); + + contextKeys = config; +} + +export function getContextKeys() { + return contextKeys; +} + +export const ContextEventName = 'lightning:context-request'; diff --git a/packages/@lwc/shared/src/index.ts b/packages/@lwc/shared/src/index.ts index 9d2c1209c5..075f150aa4 100644 --- a/packages/@lwc/shared/src/index.ts +++ b/packages/@lwc/shared/src/index.ts @@ -8,6 +8,7 @@ import * as assert from './assert'; export * from './api-version'; export * from './aria'; +export * from './context'; export * from './language'; export * from './keys'; export * from './void-elements'; diff --git a/packages/lwc/__tests__/isomorphic-exports.spec.ts b/packages/lwc/__tests__/isomorphic-exports.spec.ts index 18fcb5e791..e4e3b2dcb9 100644 --- a/packages/lwc/__tests__/isomorphic-exports.spec.ts +++ b/packages/lwc/__tests__/isomorphic-exports.spec.ts @@ -25,6 +25,7 @@ describe('isomorphic package exports', () => { 'hydrateComponent', 'isNodeFromTemplate', 'rendererFactory', + 'setContextKeys', 'setTrustedSignalSet', ]); }); diff --git a/scripts/bundlesize/bundlesize.config.json b/scripts/bundlesize/bundlesize.config.json index e01e9f01e2..b457893ec8 100644 --- a/scripts/bundlesize/bundlesize.config.json +++ b/scripts/bundlesize/bundlesize.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "packages/@lwc/engine-dom/dist/index.js", - "maxSize": "24KB" + "maxSize": "25KB" }, { "path": "packages/@lwc/synthetic-shadow/dist/index.js",