Skip to content

Commit da24af6

Browse files
johnjenkinsJohn Jenkins
andauthored
fix(ssr): expand ::part css selectors for ssr scoped components (#6298)
* fix(ssr): expand ::part css selectors for ssr `scoped` components * chore: lint --------- Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com>
1 parent 1304ffc commit da24af6

10 files changed

Lines changed: 261 additions & 16 deletions

File tree

src/client/client-style.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ import type * as d from '../declarations';
22

33
export const styles: d.StyleMap = /*@__PURE__*/ new Map();
44
export const modeResolutionChain: d.ResolutionHandler[] = [];
5+
export const setScopedSSR = (_opts: d.HydrateFactoryOptions) => {};
6+
export const needsScopedSSR = () => false;

src/hydrate/platform/hydrate-app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { globalScripts } from '@app-globals';
2-
import { addHostEventListeners, getHostRef, loadModule, plt, registerHost } from '@platform';
2+
import { addHostEventListeners, getHostRef, loadModule, plt, registerHost, setScopedSSR } from '@platform';
33
import { connectedCallback, insertVdomAnnotations } from '@runtime';
44
import { CMP_FLAGS } from '@utils';
55

@@ -24,6 +24,7 @@ export function hydrateApp(
2424
const orgDocumentCreateElement = win.document.createElement;
2525
const orgDocumentCreateElementNS = win.document.createElementNS;
2626
const resolved = Promise.resolve();
27+
setScopedSSR(opts);
2728

2829
let tmrId: any;
2930
let ranCompleted = false;

src/hydrate/platform/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,18 @@ export const Build: d.UserBuildConditionals = {
170170
export const styles: d.StyleMap = new Map();
171171
export const modeResolutionChain: d.ResolutionHandler[] = [];
172172

173+
/**
174+
* Checks to see any components are rendered with `scoped`
175+
* @param opts - SSR options
176+
*/
177+
export const setScopedSSR = (opts: d.HydrateFactoryOptions) => {
178+
scopedSSR =
179+
BUILD.shadowDom && opts.serializeShadowRoot !== false && opts.serializeShadowRoot !== 'declarative-shadow-dom';
180+
};
181+
export const needsScopedSSR = () => scopedSSR;
182+
183+
let scopedSSR = false;
184+
173185
export { hAsync as h } from './h-async';
174186
export { hydrateApp } from './hydrate-app';
175187
export { BUILD, Env, NAMESPACE } from '@app-data';

src/runtime/initialize-component.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { BUILD } from '@app-data';
2-
import { consoleError, loadModule, styles } from '@platform';
2+
import { consoleError, loadModule, needsScopedSSR, styles } from '@platform';
33
import { CMP_FLAGS, HOST_FLAGS } from '@utils';
44

55
import type * as d from '../declarations';
6-
import { scopeCss } from '../utils/shadow-css';
6+
import { expandPartSelectors, scopeCss } from '../utils/shadow-css';
77
import { computeMode } from './mode';
88
import { createTime, uniqueTime } from './profile';
99
import { proxyComponent } from './proxy-component';
@@ -156,8 +156,12 @@ export const initializeComponent = async (
156156
if (!styles.has(scopeId)) {
157157
const endRegisterStyles = createTime('registerStyles', cmpMeta.$tagName$);
158158

159-
if (BUILD.hydrateServerSide && BUILD.shadowDom && cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss) {
160-
style = scopeCss(style, scopeId, true);
159+
if (BUILD.hydrateServerSide && BUILD.shadowDom) {
160+
if (cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss) {
161+
style = scopeCss(style, scopeId, true);
162+
} else if (needsScopedSSR()) {
163+
style = expandPartSelectors(style);
164+
}
161165
}
162166
registerStyle(scopeId, style, !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation));
163167
endRegisterStyles();

src/testing/platform/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ export { flushAll, flushLoadModule, flushQueue, loadModule, nextTick, readTask,
2020
export { win } from './testing-window';
2121
export { Env } from '@app-data';
2222
export * from '@runtime';
23+
export const setScopedSSR = (scoped?: boolean) => {
24+
scopedSSR = scoped;
25+
};
26+
export const needsScopedSSR = () => scopedSSR;
27+
28+
let scopedSSR = false;

src/utils/shadow-css.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ const safeSelector = (selector: string) => {
1616
const placeholders: string[] = [];
1717
let index = 0;
1818

19+
// Replaces [part=~"..."] attribute selectors with placeholders.
20+
// As we do not want to add the scoped selector to these selectors
21+
selector = selector.replace(/(\[\s*part~=\s*("[^"]*"|'[^']*')\s*\])/g, (_, keep) => {
22+
const replaceBy = `__part-${index}__`;
23+
placeholders.push(keep);
24+
index++;
25+
return replaceBy;
26+
});
27+
1928
// Replaces attribute selectors with placeholders.
2029
// The WS in [attr="va lue"] would otherwise be interpreted as a selector separator.
2130
selector = selector.replace(/(\[[^\]]*\])/g, (_, keep) => {
@@ -42,6 +51,7 @@ const safeSelector = (selector: string) => {
4251
};
4352

4453
const restoreSafeSelector = (placeholders: string[], content: string) => {
54+
content = content.replace(/__part-(\d+)__/g, (_, index) => placeholders[+index]);
4555
return content.replace(/__ph-(\d+)__/g, (_, index) => placeholders[+index]);
4656
};
4757

@@ -71,6 +81,7 @@ const _cssColonSlottedRe = new RegExp('(' + _polyfillSlotted + _parenSuffix, 'gi
7181
const _polyfillHostNoCombinator = _polyfillHost + '-no-combinator';
7282
const _polyfillHostNoCombinatorRe = /-shadowcsshost-no-combinator([^\s]*)/;
7383
const _shadowDOMSelectorsRe = [/::shadow/g, /::content/g];
84+
const _safePartRe = /__part-(\d+)__/g;
7485

7586
const _selectorReSuffix = '([>\\s~+[.,{:][\\s\\S]*)?$';
7687
const _polyfillHostRe = /-shadowcsshost/gim;
@@ -408,7 +419,7 @@ const applyStrictSelectorScope = (selector: string, scopeSelector: string, hostS
408419
let scopedSelector = '';
409420
let startIndex = 0;
410421
let res: RegExpExecArray | null;
411-
const sep = /( |>|\+|~(?!=))\s*/g;
422+
const sep = /( |>|\+|~(?!=))(?=(?:[^()]*\([^()]*\))*[^()]*$)\s*/g;
412423

413424
// If a selector appears before :host it should not be shimmed as it
414425
// matches on ancestor elements and not on elements in the host's shadow
@@ -435,7 +446,7 @@ const applyStrictSelectorScope = (selector: string, scopeSelector: string, hostS
435446
}
436447

437448
const part = selector.substring(startIndex);
438-
shouldScope = shouldScope || part.indexOf(_polyfillHostNoCombinator) > -1;
449+
shouldScope = !part.match(_safePartRe) && (shouldScope || part.indexOf(_polyfillHostNoCombinator) > -1);
439450
scopedSelector += shouldScope ? _scopeSelectorPart(part) : part;
440451

441452
// replace the placeholders with their original values
@@ -533,6 +544,58 @@ const replaceShadowCssHost = (cssText: string, hostScopeId: string) => {
533544
return cssText.replace(/-shadowcsshost-no-combinator/g, `.${hostScopeId}`);
534545
};
535546

547+
/**
548+
* Expands selectors with ::part(...) to also include [part~="..."] selectors.
549+
* For example:
550+
* ```css
551+
* selectors-like-this::part(demo) { ... }
552+
* .something .selectors::part(demo demo2):hover { ... }
553+
* ```
554+
* Becomes:
555+
* ```
556+
* selectors-like-this::part(demo), selectors-like-this [part~="demo"] { ... }
557+
* .something .selectors::part(demo demo2):hover, .something .selectors [part~="demo"][part~="demo2"]:hover { ... }
558+
* ```
559+
*
560+
* @param cssText The CSS text to process
561+
* @returns The CSS text with expanded ::part(...) selectors
562+
*/
563+
export const expandPartSelectors = (cssText: string) => {
564+
// Regex matches: (selector before)::part(part names)(pseudo after)
565+
const partSelectorRe = /([^\s,{][^,{]*?)::part\(\s*([^)]+?)\s*\)((?:[:.][^,{]*)*)/g;
566+
return processRules(cssText, (rule: CssRule) => {
567+
if (rule.selector[0] === '@') {
568+
return rule;
569+
}
570+
// Split by comma, process each selector
571+
const selectors = rule.selector.split(',').map((sel) => {
572+
const out = [sel.trim()];
573+
let m;
574+
// For each ::part(...) in the selector, add the expanded version
575+
while ((m = partSelectorRe.exec(sel)) !== null) {
576+
const before = m[1].trimEnd();
577+
const partNames = m[2].trim().split(/\s+/);
578+
const after = m[3] || '';
579+
const partAttr = partNames
580+
.flatMap((p: string): string[] => {
581+
if (!rule.selector.includes(`[part~="${p}"]`)) {
582+
return [`[part~="${p}"]`];
583+
}
584+
return [];
585+
})
586+
.join('');
587+
const expanded = `${before} ${partAttr}${after}`;
588+
if (!!partAttr && expanded !== sel.trim()) {
589+
out.push(expanded);
590+
}
591+
}
592+
return out.join(', ');
593+
});
594+
rule.selector = selectors.join(', ');
595+
return rule;
596+
});
597+
};
598+
536599
export const scopeCss = (cssText: string, scopeId: string, commentOriginalSelector: boolean) => {
537600
const hostScopeId = scopeId + '-h';
538601
const slotScopeId = scopeId + '-s';
@@ -585,5 +648,8 @@ export const scopeCss = (cssText: string, scopeId: string, commentOriginalSelect
585648
cssText = cssText.replace(regex, slottedSelector.updatedSelector);
586649
});
587650

651+
// Expand ::part(...) selectors
652+
cssText = expandPartSelectors(cssText);
653+
588654
return cssText;
589655
};

src/utils/test/scope-css.spec.ts

Lines changed: 29 additions & 9 deletions
Large diffs are not rendered by default.

test/wdio/ssr-hydration/cmp.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,4 +322,68 @@ describe('Sanity check SSR > Client hydration', () => {
322322
childComponent.childNodes[0].textContent = 'Should be first';
323323
childComponent.childNodes[1].textContent = 'Should be second';
324324
});
325+
326+
it('correctly renders ::part css selectors for scoped components', async () => {
327+
// purposefully load in the iframe where these components are not defined
328+
// so we get only render static HTML
329+
330+
await setupIFrameTest('/ssr-hydration/custom-element.html', 'dsd-custom-elements');
331+
const frameEle: HTMLIFrameElement = document.querySelector('iframe#dsd-custom-elements');
332+
const doc = frameEle.contentDocument;
333+
334+
// scoped in dsd component
335+
336+
let result = await renderToString(
337+
`
338+
<div>
339+
<part-wrap-ssr-shadow-cmp>Inside shadowroot</wrap-ssr-shadow-cmp>
340+
</div>`,
341+
{
342+
fullDocument: true,
343+
serializeShadowRoot: {
344+
default: 'declarative-shadow-dom',
345+
scoped: ['part-ssr-shadow-cmp'],
346+
},
347+
},
348+
);
349+
let stage = doc.createElement('div');
350+
stage.setAttribute('id', 'stage');
351+
stage.setHTMLUnsafe(result.html);
352+
doc.body.appendChild(stage);
353+
354+
let childComponentPart = doc
355+
.querySelector('part-wrap-ssr-shadow-cmp')
356+
.shadowRoot.querySelector('part-ssr-shadow-cmp [part="container"]');
357+
await browser.waitUntil(async () => !!childComponentPart);
358+
await browser.pause(100);
359+
360+
await expect(getComputedStyle(childComponentPart).backgroundColor).toBe('rgb(255, 192, 203)'); // pink
361+
362+
// scoped in scoped component
363+
364+
// clear the stage
365+
doc.querySelector('#stage')?.remove();
366+
await browser.waitUntil(async () => !doc.querySelector('#stage'));
367+
368+
result = await renderToString(
369+
`
370+
<div>
371+
<part-wrap-ssr-shadow-cmp>Inside shadowroot</wrap-ssr-shadow-cmp>
372+
</div>`,
373+
{
374+
fullDocument: true,
375+
serializeShadowRoot: 'scoped',
376+
},
377+
);
378+
stage = doc.createElement('div');
379+
stage.setAttribute('id', 'stage');
380+
stage.setHTMLUnsafe(result.html);
381+
doc.body.appendChild(stage);
382+
383+
childComponentPart = doc.querySelector('part-wrap-ssr-shadow-cmp part-ssr-shadow-cmp [part="container"]');
384+
await browser.waitUntil(async () => !!childComponentPart);
385+
await browser.pause(100);
386+
387+
await expect(getComputedStyle(childComponentPart).backgroundColor).toBe('rgb(255, 192, 203)'); // pink
388+
});
325389
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Build, Component, h, Prop } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'part-ssr-shadow-cmp',
5+
shadow: true,
6+
styles: `
7+
:host {
8+
display: block;
9+
padding: 10px;
10+
border: 2px solid #000;
11+
background: yellow;
12+
color: red;
13+
}
14+
`,
15+
})
16+
export class SsrShadowCmp {
17+
@Prop() selected: boolean;
18+
19+
render() {
20+
return (
21+
<div
22+
class={{
23+
selected: this.selected,
24+
}}
25+
part="container"
26+
>
27+
<slot name="top" />
28+
<slot />
29+
{Build.isBrowser && <slot name="client-only" />}
30+
</div>
31+
);
32+
}
33+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Component, h, Prop } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'part-wrap-ssr-shadow-cmp',
5+
shadow: true,
6+
styles: `
7+
:host {
8+
display: block;
9+
padding: 10px;
10+
border: 2px solid #000;
11+
background: blue;
12+
color: white;
13+
}
14+
part-ssr-shadow-cmp::part(container) {
15+
border: 2px solid red;
16+
background: pink;
17+
}
18+
`,
19+
})
20+
export class SsrWrapShadowCmp {
21+
@Prop() selected: boolean;
22+
23+
render() {
24+
return (
25+
<div
26+
class={{
27+
selected: this.selected,
28+
}}
29+
>
30+
Nested component:
31+
<part-ssr-shadow-cmp>
32+
<slot />
33+
</part-ssr-shadow-cmp>
34+
</div>
35+
);
36+
}
37+
}

0 commit comments

Comments
 (0)