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 15 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
150 changes: 69 additions & 81 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 All @@ -15,8 +13,10 @@ interface NamedSlot extends AnonymousSlot {

export type Slot = NamedSlot | AnonymousSlot;

export type SlotName = string | null;

export interface SlotsConfig {
slots: (string | null)[];
slots: SlotName[];
/**
* Object mapping new slot name keys to deprecated slot name values
* @example `pf-modal--header` is deprecated in favour of `header`
Expand All @@ -32,9 +32,9 @@ export interface SlotsConfig {
deprecations?: Record<string, string>;
}

function isObjectConfigSpread(
config: ([SlotsConfig] | (string | null)[]),
): config is [SlotsConfig] {
export type SlotControllerArgs = [SlotsConfig] | SlotName[];

export function isObjectSpread(config: SlotControllerArgs): config is [SlotsConfig] {
return config.length === 1 && typeof config[0] === 'object' && config[0] !== null;
}

Expand All @@ -57,58 +57,92 @@ export class SlotController implements ReactiveController {

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

#logger: Logger;

#firstUpdated = false;
#slotMapInitialized = false;

#mo = new MutationObserver(records => this.#onMutation(records));

#slotNames: (string | null)[];
#slotNames: (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) {
this.#initialize(...args);
host.addController(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 });
// 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
&& !!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 +177,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 +194,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);
};
}
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
9 changes: 6 additions & 3 deletions core/pfe-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
"./controllers/property-observer-controller.js": "./controllers/property-observer-controller.js",
"./controllers/roving-tabindex-controller.js": "./controllers/roving-tabindex-controller.js",
"./controllers/scroll-spy-controller.js": "./controllers/scroll-spy-controller.js",
"./controllers/slot-controller.js": "./controllers/slot-controller.js",
"./controllers/slot-controller.js": {
"import": "./controllers/slot-controller.js",
"default": "./controllers/slot-controller.js"
},
"./controllers/style-controller.js": "./controllers/style-controller.js",
"./controllers/timestamp-controller.js": "./controllers/timestamp-controller.js",
"./controllers/tabs-controller.js": "./controllers/tabs-controller.js",
Expand Down Expand Up @@ -62,8 +65,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;
}
};
2 changes: 1 addition & 1 deletion elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
"@lit/context": "^1.1.2",
"@patternfly/icons": "^1.0.3",
"@patternfly/pfe-core": "^4.0.1",
"lit": "^3.2.0",
"lit": "^3.2.1",
"tslib": "^2.6.3"
}
}
4 changes: 3 additions & 1 deletion elements/pf-back-to-top/pf-back-to-top.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ export class PfBackToTop extends LitElement {
}

this.#scrollSpy = !!this.scrollableSelector;
if (this.#scrollSpy && this.scrollableSelector) {
if (isServer) {
return;
} else if (this.#scrollSpy && this.scrollableSelector) {
const scrollableElement = this.#rootNode?.querySelector?.(this.scrollableSelector);
if (!scrollableElement) {
this.#logger.error(`unable to find element with selector ${this.scrollableSelector}`);
Expand Down
Loading
Loading