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

fix(core): ssr events #2891

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 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
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