Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/clear-pugs-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@patternfly/pfe-core": patch
"@patternfly/elements": patch
---
Enable context protocol in SSR scenarios.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.10.0
v22.13.0
14 changes: 9 additions & 5 deletions core/pfe-core/controllers/light-dom-controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactiveController, ReactiveElement } from 'lit';
import { isServer, type ReactiveController, type ReactiveElement } from 'lit';

import { Logger } from './logger.js';

Expand Down Expand Up @@ -52,9 +52,13 @@ export class LightDOMController implements ReactiveController {
* Returns a boolean statement of whether or not this component contains any light DOM.
*/
hasLightDOM(): boolean {
return !!(
this.host.children.length > 0
|| (this.host.textContent ?? '').trim().length > 0
);
if (isServer) {
return false;
} else {
return !!(
this.host.children.length > 0
|| (this.host.textContent ?? '').trim().length > 0
);
}
}
}
4 changes: 2 additions & 2 deletions core/pfe-core/controllers/scroll-spy-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class ScrollSpyController implements ReactiveController {
#rootMargin?: string;
#threshold: number | number[];

#getRootNode: () => Node;
#getRootNode: () => Node | null;
#getHash: (el: Element) => string | null;

get #linkChildren(): Element[] {
Expand Down Expand Up @@ -92,7 +92,7 @@ export class ScrollSpyController implements ReactiveController {
this.#rootMargin = options.rootMargin;
this.#activeAttribute = options.activeAttribute ?? 'active';
this.#threshold = options.threshold ?? 0.85;
this.#getRootNode = () => options.rootNode ?? host.getRootNode();
this.#getRootNode = () => options.rootNode ?? host.getRootNode?.() ?? null;
this.#getHash = options?.getHash ?? ((el: Element) => el.getAttribute('href'));
}

Expand Down
156 changes: 79 additions & 77 deletions core/pfe-core/controllers/slot-controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { isServer, type ReactiveController, type ReactiveElement } from 'lit';

import { Logger } from './logger.js';

interface AnonymousSlot {
hasContent: boolean;
elements: Element[];
Expand Down Expand Up @@ -32,7 +30,9 @@ export interface SlotsConfig {
deprecations?: Record<string, string>;
}

function isObjectConfigSpread(
export type SlotControllerArgs = [SlotsConfig] | (string | null)[];

function isObjectSpread(
config: ([SlotsConfig] | (string | null)[]),
): config is [SlotsConfig] {
return config.length === 1 && typeof config[0] === 'object' && config[0] !== null;
Expand All @@ -55,60 +55,108 @@ export class SlotController implements ReactiveController {
/** @deprecated use `default` */
public static anonymous: symbol = this.default;

#nodes = new Map<string | typeof SlotController.default, Slot>();
private static singletons = new WeakMap<ReactiveElement, SlotController>();

#logger: Logger;
#nodes = new Map<string | typeof SlotController.default, Slot>();

#firstUpdated = false;
#slotMapInitialized = false;

#mo = new MutationObserver(records => this.#onMutation(records));
#slotNames: (string | null)[] = [];

#slotNames: (string | null)[];
#ssrHintHasSlotted: (string | null)[] = [];

#deprecations: Record<string, string> = {};

constructor(public host: ReactiveElement, ...config: ([SlotsConfig] | (string | null)[])) {
this.#logger = new Logger(this.host);
#mo = new MutationObserver(this.#initSlotMap.bind(this));

if (isObjectConfigSpread(config)) {
constructor(public host: ReactiveElement, ...args: SlotControllerArgs) {
const singleton = SlotController.singletons.get(host);
if (singleton) {
singleton.#initialize(...args);
return singleton;
}
this.#initialize(...args);
host.addController(this);
SlotController.singletons.set(host, this);
if (!this.#slotNames.length) {
this.#slotNames = [null];
}
}

#initialize(...config: SlotControllerArgs) {
if (isObjectSpread(config)) {
const [{ slots, deprecations }] = config;
this.#slotNames = slots;
this.#deprecations = deprecations ?? {};
} else if (config.length >= 1) {
this.#slotNames = config;
this.#deprecations = {};
} else {
this.#slotNames = [null];
}


host.addController(this);
}

async hostConnected(): Promise<void> {
this.host.addEventListener('slotchange', this.#onSlotChange as EventListener);
this.#firstUpdated = false;
this.#mo.observe(this.host, { childList: true });
this.#ssrHintHasSlotted =
this.host
// @ts-expect-error: this is a ponyfill for ::has-slotted, is not intended as a public API
.ssrHintHasSlotted
?? [];
// Map the defined slots into an object that is easier to query
this.#nodes.clear();
// Loop over the properties provided by the schema
this.#slotNames.forEach(this.#initSlot);
Object.values(this.#deprecations).forEach(this.#initSlot);
this.host.requestUpdate();
this.#initSlotMap();
// insurance for framework integrations
await this.host.updateComplete;
this.host.requestUpdate();
}

hostDisconnected(): void {
this.#mo.disconnect();
}

hostUpdated(): void {
if (!this.#firstUpdated) {
this.#slotNames.forEach(this.#initSlot);
this.#firstUpdated = true;
if (!this.#slotMapInitialized) {
this.#initSlotMap();
}
}

hostDisconnected(): void {
this.#mo.disconnect();
#initSlotMap() {
// Loop over the properties provided by the schema
for (const slotName of this.#slotNames
.concat(Object.values(this.#deprecations))) {
const slotId = slotName || SlotController.default;
const name = slotName ?? '';
const elements = this.#getChildrenForSlot(slotId);
const slot = this.#getSlotElement(slotId);
const hasContent =
isServer ? this.#ssrHintHasSlotted.includes(slotName)
: !!elements.length || !!slot?.assignedNodes?.()?.filter(x => x.textContent?.trim()).length;
this.#nodes.set(slotId, { elements, name, hasContent, slot });
}
this.host.requestUpdate();
this.#slotMapInitialized = true;
}

#getSlotElement(slotId: string | symbol) {
if (isServer) {
return null;
} else {
const selector =
slotId === SlotController.default ? 'slot:not([name])' : `slot[name="${slotId as string}"]`;
return this.host.shadowRoot?.querySelector?.<HTMLSlotElement>(selector) ?? null;
}
}

#getChildrenForSlot<T extends Element = Element>(
name: string | typeof SlotController.default,
): T[] {
if (isServer) {
return [];
} else if (this.#nodes.has(name)) {
return (this.#nodes.get(name)!.slot?.assignedElements?.() ?? []) as T[];
} else {
const children = Array.from(this.host.children) as T[];
return children.filter(isSlot(name));
}
}

/**
Expand Down Expand Up @@ -143,19 +191,11 @@ export class SlotController implements ReactiveController {
* @example this.hasSlotted('header');
*/
hasSlotted(...names: (string | null | undefined)[]): boolean {
if (isServer) {
return this.host
.getAttribute('ssr-hint-has-slotted')
?.split(',')
.map(name => name.trim())
.some(name => names.includes(name === 'default' ? null : name)) ?? false;
} else {
const slotNames = Array.from(names, x => x == null ? SlotController.default : x);
if (!slotNames.length) {
slotNames.push(SlotController.default);
}
return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false);
const slotNames = Array.from(names, x => x == null ? SlotController.default : x);
if (!slotNames.length) {
slotNames.push(SlotController.default);
}
return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false);
}

/**
Expand All @@ -168,42 +208,4 @@ export class SlotController implements ReactiveController {
isEmpty(...names: (string | null | undefined)[]): boolean {
return !this.hasSlotted(...names);
}

#onSlotChange = (event: Event & { target: HTMLSlotElement }) => {
const slotName = event.target.name;
this.#initSlot(slotName);
this.host.requestUpdate();
};

#onMutation = async (records: MutationRecord[]) => {
const changed = [];
for (const { addedNodes, removedNodes } of records) {
for (const node of [...addedNodes, ...removedNodes]) {
if (node instanceof HTMLElement && node.slot) {
this.#initSlot(node.slot);
changed.push(node.slot);
}
}
}
this.host.requestUpdate();
};

#getChildrenForSlot<T extends Element = Element>(
name: string | typeof SlotController.default,
): T[] {
const children = Array.from(this.host.children) as T[];
return children.filter(isSlot(name));
}

#initSlot = (slotName: string | null) => {
const name = slotName || SlotController.default;
const elements = this.#nodes.get(name)?.slot?.assignedElements?.()
?? this.#getChildrenForSlot(name);
const selector = slotName ? `slot[name="${slotName}"]` : 'slot:not([name])';
const slot = this.host.shadowRoot?.querySelector?.<HTMLSlotElement>(selector) ?? null;
const nodes = slot?.assignedNodes?.();
const hasContent = !!elements.length || !!nodes?.filter(x => x.textContent?.trim()).length;
this.#nodes.set(name, { elements, name: slotName ?? '', hasContent, slot });
this.#logger.debug(slotName, hasContent);
};
}
27 changes: 27 additions & 0 deletions core/pfe-core/decorators/slots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ReactiveElement } from 'lit';

import { SlotController, type SlotControllerArgs } from '../controllers/slot-controller.js';

/**
* Enable ssr hints for element
* @param args a spread of slot names, or a config object.
* @see SlotController constructor args
*/
export function slots<T extends typeof ReactiveElement>(...args: SlotControllerArgs) {
return function(klass: T): void {
klass.createProperty('ssrHintHasSlotted', {
attribute: 'ssr-hint-has-slotted',
converter: {
fromAttribute(slots) {
return (slots ?? '')
.split(/[, ]/)
.map(x => x.trim())
.map(x => x === 'default' ? null : x);
},
},
});
klass.addInitializer(instance => {
new SlotController(instance, ...args);
});
};
}
5 changes: 5 additions & 0 deletions core/pfe-core/functions/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ function makeContextRoot() {
const root = new ContextRoot();
if (!isServer) {
root.attach(document.body);
} else {
root.attach(
// @ts-expect-error: enable context root in ssr
globalThis.litServerRoot,
);
}
return root;
}
Expand Down
4 changes: 2 additions & 2 deletions core/pfe-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@
},
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"lit": "^3.2.0"
"@lit/context": "^1.1.3",
"lit": "^3.2.1"
},
"repository": {
"type": "git",
Expand Down
54 changes: 25 additions & 29 deletions core/pfe-core/ssr-shims.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { installWindowOnGlobal } from '@lit-labs/ssr/lib/dom-shim.js';

class ObserverShim {
observe(): void {
void 0;
Expand All @@ -17,33 +19,7 @@ class MiniHTMLTemplateElement extends MiniHTMLElement {
content = { cloneNode: (): string => this.innerHTML };
}

class MiniDocument {
createElement(tagName: string): MiniHTMLElement {
switch (tagName) {
case 'template':
return new MiniHTMLTemplateElement(tagName);
default:
return new MiniHTMLElement(tagName);
}
}
}

// @ts-expect-error: this runs in node
globalThis.window ??= globalThis;
// @ts-expect-error: this runs in node
globalThis.document ??= new MiniDocument();
// @ts-expect-error: this runs in node
globalThis.navigator ??= { userAgent: '' };
// @ts-expect-error: this runs in node
globalThis.ErrorEvent ??= Event;
// @ts-expect-error: this runs in node
globalThis.IntersectionObserver ??= ObserverShim;
// @ts-expect-error: this runs in node
globalThis.MutationObserver ??= ObserverShim;
// @ts-expect-error: this runs in node
globalThis.ResizeObserver ??= ObserverShim;
// @ts-expect-error: this runs in node
globalThis.getComputedStyle ??= function() {
function getComputedStyle() {
return {
getPropertyPriority() {
return '';
Expand All @@ -52,7 +28,27 @@ globalThis.getComputedStyle ??= function() {
return '';
},
};
}
};

;
// @ts-expect-error: opt in to event support in ssr
globalThis.litSsrCallConnectedCallback = true;

installWindowOnGlobal({
ErrorEvent: Event,
IntersectionObserver: ObserverShim,
MutationObserver: ObserverShim,
ResizeObserver: ObserverShim,
getComputedStyle,
});

// @ts-expect-error: this runs in node
globalThis.navigator.userAgent ??= '@lit-labs/ssr';

globalThis.document.createElement = function createElement(tagName: string): HTMLElement {
switch (tagName) {
case 'template':
return new MiniHTMLTemplateElement(tagName) as unknown as HTMLElement;
default:
return new MiniHTMLElement(tagName) as HTMLElement;
}
};
Loading
Loading