-
Notifications
You must be signed in to change notification settings - Fork 414
@W-17388345 - add context support to ssrv2 component lifecycle #5387
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
Changes from all commits
cfb5f7d
719a6e9
a807713
bd16f3e
53bc835
3efd32a
dce0888
7385cc2
af1465d
f4e6b89
24fba78
32af684
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,8 @@ import { LightningElement, api } from 'lwc'; | |
import { defineMalformedContext } from 'x/contextManager'; | ||
export default class Root extends LightningElement { | ||
@api showTree = false; | ||
malformedContext = defineMalformedContext()(); | ||
// Only test in CSR right now as SSR throws which prevents content from being rendered. There is additional fixtures ssr coverage for this case. | ||
malformedContext = typeof window !== 'undefined' ? defineMalformedContext()() : undefined; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for leaving this comment. |
||
|
||
connectedCallback() { | ||
this.showTree = true; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
import * as shared from '@lwc/shared'; | ||
import { connectContext, ContextBinding } from '../context'; | ||
import { LightningElement, SYMBOL__CONTEXT_VARIETIES } from '../lightning-element'; | ||
import { getContextfulStack } from '../wire'; | ||
import type { Signal } from '@lwc/signals'; | ||
import type { ContextProvidedCallback } from '@lwc/shared'; | ||
|
||
// Mock the wire module | ||
vi.mock('../wire', () => ({ | ||
getContextfulStack: vi.fn(), | ||
})); | ||
|
||
// Mock @lwc/shared | ||
vi.mock('@lwc/shared', () => ({ | ||
isTrustedContext: vi.fn(), | ||
getContextKeys: vi.fn(), | ||
isUndefined: vi.fn(), | ||
keys: vi.fn(), | ||
entries: vi.fn().mockReturnValue([]), | ||
AriaAttrNameToPropNameMap: {}, | ||
defineProperties: vi.fn(), | ||
assign: vi.fn(), | ||
ArrayFilter: { | ||
call: vi.fn(), | ||
}, | ||
})); | ||
|
||
describe('context', () => { | ||
let mockContextKeys: { connectContext: string }; | ||
let mockContextfulStack: LightningElement[]; | ||
let mockContextVariety: object; | ||
let mockContextSignal: Signal<unknown>; | ||
let mockContextProvidedCallback: ContextProvidedCallback; | ||
|
||
beforeEach(() => { | ||
// Reset mocks | ||
vi.clearAllMocks(); | ||
|
||
// Setup mock context keys | ||
mockContextKeys = { | ||
connectContext: 'connectContext', | ||
}; | ||
|
||
// Setup mock contextful stack | ||
mockContextfulStack = [new LightningElement({ tagName: 'div' })]; | ||
(getContextfulStack as any).mockReturnValue(mockContextfulStack); | ||
|
||
// Setup mock context variety and signal | ||
mockContextVariety = {}; | ||
mockContextSignal = { value: undefined } as Signal<unknown>; | ||
mockContextProvidedCallback = vi.fn(); | ||
|
||
// Mock global context keys | ||
(global as any).__LWC_CONTEXT_KEYS__ = mockContextKeys; | ||
|
||
// Mock @lwc/shared functions | ||
(shared.getContextKeys as any).mockReturnValue(mockContextKeys); | ||
(shared.isUndefined as any).mockImplementation((val: unknown) => val === undefined); | ||
(shared.keys as any).mockReturnValue(['contextful']); | ||
(shared.isTrustedContext as any).mockReturnValue(true); | ||
(shared.ArrayFilter.call as any).mockReturnValue(['contextful']); | ||
}); | ||
|
||
describe('connectContext', () => { | ||
it('should do nothing if context keys are undefined', () => { | ||
(shared.getContextKeys as any).mockReturnValue(undefined); | ||
const component = new LightningElement({ tagName: 'div' }); | ||
connectContext(component); | ||
expect(getContextfulStack).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('should do nothing if no contextful keys are found', () => { | ||
(shared.ArrayFilter.call as any).mockReturnValue([]); | ||
const component = new LightningElement({ tagName: 'div' }); | ||
connectContext(component); | ||
expect(getContextfulStack).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('should connect context when contextful keys are found', () => { | ||
const component = new LightningElement({ tagName: 'div' }); | ||
const mockContextful = { | ||
[mockContextKeys.connectContext]: vi.fn(), | ||
}; | ||
Object.defineProperty(component, 'contextful', { | ||
value: mockContextful, | ||
configurable: true, | ||
}); | ||
|
||
connectContext(component); | ||
|
||
expect(mockContextful[mockContextKeys.connectContext]).toHaveBeenCalled(); | ||
}); | ||
|
||
it('should throw error in development when connecting context fails', () => { | ||
const originalNodeEnv = process.env.NODE_ENV; | ||
process.env.NODE_ENV = 'development'; | ||
|
||
const component = new LightningElement({ tagName: 'div' }); | ||
const mockContextful = { | ||
[mockContextKeys.connectContext]: vi.fn().mockImplementation(() => { | ||
throw new Error('Connection failed'); | ||
}), | ||
}; | ||
Object.defineProperty(component, 'contextful', { | ||
value: mockContextful, | ||
configurable: true, | ||
}); | ||
|
||
expect(() => connectContext(component)).toThrow( | ||
'Attempted to connect to trusted context' | ||
); | ||
|
||
process.env.NODE_ENV = originalNodeEnv; | ||
}); | ||
}); | ||
|
||
describe('ContextBinding', () => { | ||
it('should provide context successfully', () => { | ||
const component = new LightningElement({ tagName: 'div' }); | ||
const binding = new ContextBinding(component); | ||
|
||
binding.provideContext(mockContextVariety, mockContextSignal); | ||
|
||
expect(component[SYMBOL__CONTEXT_VARIETIES].has(mockContextVariety)).toBe(true); | ||
expect(component[SYMBOL__CONTEXT_VARIETIES].get(mockContextVariety)).toBe( | ||
mockContextSignal | ||
); | ||
}); | ||
|
||
it('should not allow multiple contexts of the same variety in development', () => { | ||
const originalNodeEnv = process.env.NODE_ENV; | ||
process.env.NODE_ENV = 'development'; | ||
|
||
const component = new LightningElement({ tagName: 'div' }); | ||
const binding = new ContextBinding(component); | ||
|
||
binding.provideContext(mockContextVariety, mockContextSignal); | ||
expect(() => { | ||
binding.provideContext(mockContextVariety, mockContextSignal); | ||
}).toThrow('Multiple contexts of the same variety were provided'); | ||
|
||
process.env.NODE_ENV = originalNodeEnv; | ||
}); | ||
|
||
it('should consume context from ancestor', () => { | ||
const component = new LightningElement({ tagName: 'div' }); | ||
const binding = new ContextBinding(component); | ||
|
||
// Setup ancestor with context | ||
const ancestor = mockContextfulStack[0]; | ||
ancestor[SYMBOL__CONTEXT_VARIETIES] = new Map([ | ||
[mockContextVariety, mockContextSignal], | ||
]); | ||
|
||
binding.consumeContext(mockContextVariety, mockContextProvidedCallback); | ||
|
||
expect(mockContextProvidedCallback).toHaveBeenCalledWith(mockContextSignal); | ||
}); | ||
|
||
it('should not call callback if context is not found in ancestors', () => { | ||
const component = new LightningElement({ tagName: 'div' }); | ||
const binding = new ContextBinding(component); | ||
|
||
// Setup empty ancestor | ||
const ancestor = mockContextfulStack[0]; | ||
ancestor[SYMBOL__CONTEXT_VARIETIES] = new Map(); | ||
|
||
binding.consumeContext(mockContextVariety, mockContextProvidedCallback); | ||
|
||
expect(mockContextProvidedCallback).not.toHaveBeenCalled(); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
/* | ||
* 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 { | ||
type ContextProvidedCallback, | ||
type ContextBinding as IContextBinding, | ||
isTrustedContext, | ||
getContextKeys, | ||
isUndefined, | ||
keys, | ||
ArrayFilter, | ||
} from '@lwc/shared'; | ||
import { getContextfulStack } from './wire'; | ||
import { type LightningElement, SYMBOL__CONTEXT_VARIETIES } from './lightning-element'; | ||
import type { Signal } from '@lwc/signals'; | ||
|
||
class ContextBinding<C extends LightningElement> implements IContextBinding<LightningElement> { | ||
component: C; | ||
|
||
constructor(component: C) { | ||
this.component = component; | ||
} | ||
|
||
provideContext<V extends object>( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know these methods are documented elsewhere, perhaps in the CSR implementation, but it'd be nice to have doc strings for these methods here as well. |
||
contextVariety: V, | ||
providedContextSignal: Signal<unknown> | ||
): void { | ||
const contextVarieties = this.component[SYMBOL__CONTEXT_VARIETIES]; | ||
if (contextVarieties.has(contextVariety)) { | ||
if (process.env.NODE_ENV !== 'production') { | ||
throw new Error('Multiple contexts of the same variety were provided.'); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a nice ergonomic touch; thank you. |
||
return; | ||
} | ||
contextVarieties.set(contextVariety, providedContextSignal); | ||
} | ||
|
||
consumeContext<V extends object>( | ||
contextVariety: V, | ||
contextProvidedCallback: ContextProvidedCallback | ||
): void { | ||
const contextfulStack = getContextfulStack(this.component); | ||
for (const ancestor of contextfulStack) { | ||
// If the ancestor has the specified context variety, consume it and stop searching | ||
const ancestorContextVarieties = ancestor[SYMBOL__CONTEXT_VARIETIES]; | ||
if (ancestorContextVarieties.has(contextVariety)) { | ||
contextProvidedCallback(ancestorContextVarieties.get(contextVariety)); | ||
break; | ||
} | ||
} | ||
} | ||
} | ||
|
||
export { ContextBinding }; | ||
|
||
export function connectContext(le: LightningElement) { | ||
const contextKeys = getContextKeys(); | ||
|
||
if (isUndefined(contextKeys)) { | ||
return; | ||
} | ||
|
||
const { connectContext } = contextKeys; | ||
|
||
const enumerableKeys = keys(le); | ||
const contextfulKeys = ArrayFilter.call(enumerableKeys, (enumerableKey) => | ||
isTrustedContext((le as any)[enumerableKey]) | ||
); | ||
|
||
if (contextfulKeys.length === 0) { | ||
return; | ||
} | ||
|
||
try { | ||
for (let i = 0; i < contextfulKeys.length; i++) { | ||
(le as any)[contextfulKeys[i]][connectContext](new ContextBinding(le)); | ||
} | ||
} catch (err: any) { | ||
if (process.env.NODE_ENV !== 'production') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we ignore errors in production? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is similar to the above where we conditionally show an error in dev/test. But because this is at the end of a function, we don't need an early-return the way we do in the other instance. |
||
throw new Error( | ||
`Attempted to connect to trusted context but received the following error: ${ | ||
err.message | ||
}` | ||
); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,8 +14,13 @@ export { | |
sanitizeHtmlContent, | ||
normalizeClass, | ||
normalizeTabIndex, | ||
setContextKeys, | ||
setTrustedSignalSet, | ||
setTrustedContextSet, | ||
} from '@lwc/shared'; | ||
|
||
export { setFeatureFlag, setFeatureFlagForTest } from '@lwc/features'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add |
||
|
||
export { ClassList } from './class-list'; | ||
export { | ||
LightningElement, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -29,13 +29,16 @@ import { ClassList } from './class-list'; | |
import { mutationTracker } from './mutation-tracker'; | ||
import { descriptors as reflectionDescriptors } from './reflection'; | ||
import { getReadOnlyProxy } from './get-read-only-proxy'; | ||
import { connectContext } from './context'; | ||
import type { Attributes, Properties } from './types'; | ||
import type { Stylesheets } from '@lwc/shared'; | ||
import type { Signal } from '@lwc/signals'; | ||
|
||
type EventListenerOrEventListenerObject = unknown; | ||
type AddEventListenerOptions = unknown; | ||
type EventListenerOptions = unknown; | ||
type ShadowRoot = unknown; | ||
type ContextVarieties = Map<unknown, Signal<unknown>>; | ||
|
||
export type LightningElementConstructor = typeof LightningElement; | ||
|
||
|
@@ -46,6 +49,7 @@ interface PropsAvailableAtConstruction { | |
export const SYMBOL__SET_INTERNALS = Symbol('set-internals'); | ||
export const SYMBOL__GENERATE_MARKUP = Symbol('generate-markup'); | ||
export const SYMBOL__DEFAULT_TEMPLATE = Symbol('default-template'); | ||
export const SYMBOL__CONTEXT_VARIETIES = Symbol('context-varieties'); | ||
|
||
export class LightningElement implements PropsAvailableAtConstruction { | ||
static renderMode?: 'light' | 'shadow'; | ||
|
@@ -73,6 +77,7 @@ export class LightningElement implements PropsAvailableAtConstruction { | |
#props!: Properties; | ||
#attrs!: Attributes; | ||
#classList: ClassList | null = null; | ||
[SYMBOL__CONTEXT_VARIETIES]: ContextVarieties = new Map(); | ||
|
||
constructor(propsAvailableAtConstruction: PropsAvailableAtConstruction & Properties) { | ||
assign(this, propsAvailableAtConstruction); | ||
|
@@ -82,6 +87,11 @@ export class LightningElement implements PropsAvailableAtConstruction { | |
this.#props = props; | ||
this.#attrs = attrs; | ||
|
||
if (lwcRuntimeFlags.ENABLE_EXPERIMENTAL_SIGNALS) { | ||
// Setup context before connected callback is executed | ||
connectContext(this); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is wonderfully straightforward :) |
||
|
||
// Class should be set explicitly to avoid it being overridden by connectedCallback classList mutation. | ||
if (attrs.class) { | ||
this.className = attrs.class; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this block deleted?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was redundant (mistake from the past)