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 all 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
31 changes: 31 additions & 0 deletions .changeset/clear-pugs-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@patternfly/pfe-core": major
"@patternfly/elements": patch
---
Enable `connectedCallback()` and context protocol in SSR scenarios.

BREAKING CHANGE
This change affects any element which is expected to execute in node JS when
lit-ssr shims are present. By enabling the `connectedCallback()` to execute
server side. Elements must ensure that their connectedCallbacks do not try to
access the DOM.

Before:

```js
connectedCallback() {
super.connectedCallback();
this.items = this.querySelectorAll('my-item');
}
```

After:
```js
connectedCallback() {
super.connectedCallback();
if (!isServer) {
this.items = this.querySelectorAll('my-item');
}
}
```

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
Loading
Loading