Skip to content

Commit f4ff1de

Browse files
committed
fix(runtime): re-attach removed style elements (#6637)
When an external framework (e.g. SvelteKit's head management) removes Stencil-injected `<style>` elements from the DOM, Stencil's in-memory cache (`rootAppliedStyles`) still considers them applied and never re-inserts them. Fix: change `rootAppliedStyles` from `Set<string>` to `Map<string, HTMLStyleElement | null>` to track both "applied" status and the DOM element reference. Before treating a scopeId as applied, verify the tracked element is still connected via `isConnected`. If the element was removed, clear the stale entry and re-create the style. For shadow roots using a shared `<style>` container (no constructable stylesheets), track the container via a companion WeakMap and re-insert the same element if removed, preserving all merged styles. Constructable stylesheets are tracked as `null` since they are immune to external DOM removal. Closes #6637
1 parent 05d12e5 commit f4ff1de

File tree

5 files changed

+528
-53
lines changed

5 files changed

+528
-53
lines changed

src/declarations/stencil-private.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1927,7 +1927,9 @@ export interface PlatformRuntime {
19271927

19281928
export type StyleMap = Map<string, CSSStyleSheet | string>;
19291929

1930-
export type RootAppliedStyleMap = WeakMap<Element, Set<string>>;
1930+
export type RootAppliedStyleContainer = Element | ShadowRoot | Document;
1931+
1932+
export type RootAppliedStyleMap = WeakMap<RootAppliedStyleContainer, Map<string, HTMLStyleElement | null>>;
19311933

19321934
export interface ScreenshotConnector {
19331935
initBuild(opts: ScreenshotConnectorOptions): Promise<void>;

src/runtime/disconnected-callback.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getHostRef, plt } from '@platform';
33

44
import type * as d from '../declarations';
55
import { PLATFORM_FLAGS } from './runtime-constants';
6-
import { rootAppliedStyles } from './styles';
6+
import { rootAppliedSharedStyleContainers, rootAppliedStyles } from './styles';
77
import { safeCall } from './update-component';
88

99
const disconnectInstance = (instance: any, elm?: d.HostElement) => {
@@ -32,17 +32,18 @@ export const disconnectedCallback = async (elm: d.HostElement) => {
3232
}
3333
}
3434

35-
/**
36-
* Remove the element from the `rootAppliedStyles` WeakMap
37-
*/
38-
if (rootAppliedStyles.has(elm)) {
39-
rootAppliedStyles.delete(elm);
40-
}
35+
const { shadowRoot } = elm;
4136

4237
/**
43-
* Remove the shadow root from the `rootAppliedStyles` WeakMap
38+
* Clean up style tracking for the disconnected element.
39+
* Note: we intentionally do NOT clear `rootAppliedStyles` for the shadow root
40+
* here — doing so would lose the tracked element references, causing duplicate
41+
* `<style>` elements if the component reconnects. The WeakMap keys are
42+
* automatically garbage collected when the container element is destroyed.
43+
* We only clear shared style containers so they get fresh tracking on reconnect.
4444
*/
45-
if (elm.shadowRoot && rootAppliedStyles.has(elm.shadowRoot as unknown as Element)) {
46-
rootAppliedStyles.delete(elm.shadowRoot as unknown as Element);
45+
rootAppliedStyles.delete(elm);
46+
if (shadowRoot) {
47+
rootAppliedSharedStyleContainers.delete(shadowRoot);
4748
}
4849
};

src/runtime/styles.ts

Lines changed: 119 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,51 @@ import { queryNonceMetaTagContent } from '../utils/query-nonce-meta-tag-content'
1515
import { createTime } from './profile';
1616
import { HYDRATED_STYLE_ID, NODE_TYPE, SLOT_FB_CSS } from './runtime-constants';
1717

18+
/**
19+
* Tracks which style scopeIds have been applied to each container, along with
20+
* a reference to the associated `<style>` DOM element (or `null` for styles
21+
* applied via constructable stylesheets, which are immune to external removal).
22+
*
23+
* Using a `Map<string, HTMLStyleElement | null>` instead of a plain `Set<string>`
24+
* lets us verify that a tracked `<style>` element is still in the DOM before
25+
* treating it as "applied". This fixes the issue where an external framework
26+
* (e.g. SvelteKit's head management) removes Stencil's `<style>` elements but
27+
* the in-memory cache still considers them applied.
28+
*/
1829
export const rootAppliedStyles: d.RootAppliedStyleMap = /*@__PURE__*/ new WeakMap();
1930

31+
/**
32+
* Tracks shared `<style>` containers in shadow roots for the fallback case
33+
* where constructable stylesheets are not supported and multiple scoped
34+
* components merge their styles into a single `<style>` element.
35+
*
36+
* This is a separate WeakMap because the mapping is per-shadow-root (not
37+
* per-scopeId) and the shared container needs to be re-inserted as a whole
38+
* if removed externally, preserving all merged styles.
39+
*/
40+
export const rootAppliedSharedStyleContainers: WeakMap<ShadowRoot, HTMLStyleElement> = /*@__PURE__*/ new WeakMap();
41+
42+
type ConstructableStylesheetWindow = Pick<typeof globalThis, 'CSSStyleSheet'>;
43+
44+
/**
45+
* Build the full style text for a component, including slot fallback CSS
46+
* (`slot-fb{display:contents}`) when the component uses slot relocation.
47+
*/
48+
const getStyleText = (cmpMeta: d.ComponentRuntimeMeta, style: string) =>
49+
cmpMeta.$flags$ & CMP_FLAGS.hasSlotRelocation ? style + SLOT_FB_CSS : style;
50+
51+
/**
52+
* Create a constructable stylesheet scoped to a shadow root's window context.
53+
* Falls back to the platform window if the owner document has no `defaultView`
54+
* (e.g. detached documents).
55+
*/
56+
export const createShadowRootConstructableStylesheet = (shadowRoot: ShadowRoot, styleText: string) => {
57+
const currentWindow = (shadowRoot.ownerDocument.defaultView ?? win) as unknown as ConstructableStylesheetWindow;
58+
const stylesheet = new currentWindow.CSSStyleSheet();
59+
stylesheet.replaceSync(styleText);
60+
return stylesheet;
61+
};
62+
2063
/**
2164
* Register the styles for a component by creating a stylesheet and then
2265
* registering it under the component's scope ID in a `WeakMap` for later use.
@@ -69,26 +112,61 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
69112

70113
if (style) {
71114
if (typeof style === 'string') {
72-
styleContainerNode = styleContainerNode.head || (styleContainerNode as HTMLElement);
73-
let appliedStyles = rootAppliedStyles.get(styleContainerNode);
74-
let styleElm;
115+
const styleText = getStyleText(cmpMeta, style);
116+
const appliedStyleContainer = (styleContainerNode.head || styleContainerNode) as Element | ShadowRoot;
117+
let appliedStyles = rootAppliedStyles.get(appliedStyleContainer);
118+
75119
if (!appliedStyles) {
76-
rootAppliedStyles.set(styleContainerNode, (appliedStyles = new Set()));
120+
rootAppliedStyles.set(appliedStyleContainer, (appliedStyles = new Map()));
121+
}
122+
123+
// Check if this scopeId has a tracked style element that is still
124+
// in its expected container. Using parentNode instead of isConnected
125+
// because the element may be in a shadow root whose host is not yet
126+
// connected to the document (e.g. during initial render).
127+
const trackedElm = appliedStyles.get(scopeId);
128+
if (trackedElm !== undefined) {
129+
if (trackedElm === null) {
130+
// Applied via constructable stylesheet — immune to external DOM removal
131+
return scopeId;
132+
}
133+
if (trackedElm.parentNode === appliedStyleContainer) {
134+
// Style element is still in the expected container — update content during HMR only
135+
if (BUILD.hotModuleReplacement && trackedElm.textContent !== styleText) {
136+
trackedElm.textContent = styleText;
137+
}
138+
return scopeId;
139+
}
140+
// Style element was removed from the DOM by an external framework
141+
// (e.g. SvelteKit head management). Remove stale tracking so we re-apply.
142+
appliedStyles.delete(scopeId);
77143
}
78144

79-
// Check if style element already exists (for HMR updates)
80-
// For shadow DOM components, directly update their dedicated style element
81-
// For scoped components, check if they have their own HMR-created style element
82-
const existingStyleElm: HTMLStyleElement =
83-
(BUILD.hydrateClientSide || BUILD.hotModuleReplacement) &&
84-
styleContainerNode.querySelector(`[${HYDRATED_STYLE_ID}="${scopeId}"]`);
145+
// For shadow roots without constructable stylesheets, check if a shared
146+
// style container was removed and needs to be re-inserted
147+
if ('host' in appliedStyleContainer) {
148+
const sharedContainer = rootAppliedSharedStyleContainers.get(appliedStyleContainer as ShadowRoot);
149+
if (sharedContainer && sharedContainer.parentNode !== appliedStyleContainer) {
150+
appliedStyleContainer.insertBefore(sharedContainer, appliedStyleContainer.firstChild);
151+
appliedStyles.set(scopeId, sharedContainer);
152+
return scopeId;
153+
}
154+
}
155+
156+
// Check for existing hydrated or HMR style element already in the DOM
157+
const existingStyleElm =
158+
(((BUILD.hydrateClientSide || BUILD.hotModuleReplacement) &&
159+
appliedStyleContainer.querySelector<HTMLStyleElement>(`[${HYDRATED_STYLE_ID}="${scopeId}"]`)) ||
160+
undefined);
85161

86162
if (existingStyleElm) {
87163
// Update existing style element (for hydration or HMR)
88-
existingStyleElm.textContent = style;
89-
} else if (!appliedStyles.has(scopeId)) {
90-
styleElm = win.document.createElement('style');
91-
styleElm.textContent = style;
164+
existingStyleElm.textContent = styleText;
165+
appliedStyles.set(scopeId, existingStyleElm);
166+
} else {
167+
const styleElm = win.document.createElement('style');
168+
styleElm.textContent = styleText;
169+
let trackedStyleElm: HTMLStyleElement | null = styleElm;
92170

93171
// Apply CSP nonce to the style tag if it exists
94172
const nonce = plt.$nonce$ ?? queryNonceMetaTagContent(win.document);
@@ -109,21 +187,21 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
109187
* attach styles at the end of the head tag if we render scoped components
110188
*/
111189
if (!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation)) {
112-
if (styleContainerNode.nodeName === 'HEAD') {
190+
if (appliedStyleContainer.nodeName === 'HEAD') {
113191
/**
114192
* if the page contains preconnect links, we want to insert the styles
115193
* after the last preconnect link to ensure the styles are preloaded
116194
*/
117-
const preconnectLinks = styleContainerNode.querySelectorAll('link[rel=preconnect]');
195+
const preconnectLinks = appliedStyleContainer.querySelectorAll('link[rel=preconnect]');
118196
const referenceNode =
119197
preconnectLinks.length > 0
120198
? preconnectLinks[preconnectLinks.length - 1].nextSibling
121-
: styleContainerNode.querySelector('style');
122-
(styleContainerNode as HTMLElement).insertBefore(
199+
: appliedStyleContainer.querySelector('style');
200+
(appliedStyleContainer as HTMLElement).insertBefore(
123201
styleElm,
124-
referenceNode?.parentNode === styleContainerNode ? referenceNode : null,
202+
referenceNode?.parentNode === appliedStyleContainer ? referenceNode : null,
125203
);
126-
} else if ('host' in styleContainerNode) {
204+
} else if ('host' in appliedStyleContainer) {
127205
if (supportsConstructableStylesheets) {
128206
/**
129207
* If a scoped component is used within a shadow root then turn the styles into a
@@ -135,19 +213,22 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
135213
* Note: constructable stylesheets can't be shared between windows,
136214
* we need to create a new one for the current window if necessary
137215
*/
138-
const currentWindow = styleContainerNode.defaultView ?? styleContainerNode.ownerDocument.defaultView;
139-
const stylesheet = new currentWindow.CSSStyleSheet();
140-
stylesheet.replaceSync(style);
216+
const stylesheet = createShadowRootConstructableStylesheet(
217+
appliedStyleContainer as ShadowRoot,
218+
styleText,
219+
);
141220

142221
/**
143222
* > If the array needs to be modified, use in-place mutations like push().
144223
* https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
145224
*/
146225
if (supportsMutableAdoptedStyleSheets) {
147-
styleContainerNode.adoptedStyleSheets.unshift(stylesheet);
226+
appliedStyleContainer.adoptedStyleSheets.unshift(stylesheet);
148227
} else {
149-
styleContainerNode.adoptedStyleSheets = [stylesheet, ...styleContainerNode.adoptedStyleSheets];
228+
appliedStyleContainer.adoptedStyleSheets = [stylesheet, ...appliedStyleContainer.adoptedStyleSheets];
150229
}
230+
231+
trackedStyleElm = null;
151232
} else {
152233
/**
153234
* If a scoped component is used within a shadow root and constructable stylesheets are
@@ -162,38 +243,34 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
162243
* During HMR, create separate style elements for scoped components so they can be
163244
* updated independently without affecting other components' styles.
164245
*/
165-
const existingStyleContainer: HTMLStyleElement = styleContainerNode.querySelector('style');
246+
const existingStyleContainer = appliedStyleContainer.querySelector<HTMLStyleElement>('style');
166247
if (existingStyleContainer && !BUILD.hotModuleReplacement) {
167-
existingStyleContainer.textContent = style + existingStyleContainer.textContent;
248+
existingStyleContainer.textContent = styleText + existingStyleContainer.textContent;
249+
existingStyleContainer.removeAttribute(HYDRATED_STYLE_ID);
250+
rootAppliedSharedStyleContainers.set(appliedStyleContainer as ShadowRoot, existingStyleContainer);
251+
trackedStyleElm = existingStyleContainer;
168252
} else {
169-
(styleContainerNode as HTMLElement).prepend(styleElm);
253+
appliedStyleContainer.insertBefore(styleElm, appliedStyleContainer.firstChild);
170254
}
171255
}
172256
} else {
173-
styleContainerNode.append(styleElm);
257+
appliedStyleContainer.append(styleElm);
174258
}
175259
}
176260

177261
/**
178262
* attach styles at the beginning of a shadow root node if we render shadow components
179263
*/
180264
if (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) {
181-
styleContainerNode.insertBefore(styleElm, null);
265+
appliedStyleContainer.insertBefore(styleElm, null);
182266
}
183267

184-
// Add styles for `slot-fb` elements if we're using slots outside the Shadow DOM
185-
if (cmpMeta.$flags$ & CMP_FLAGS.hasSlotRelocation) {
186-
styleElm.textContent += SLOT_FB_CSS;
187-
}
188-
189-
if (appliedStyles) {
190-
appliedStyles.add(scopeId);
191-
}
268+
appliedStyles.set(scopeId, trackedStyleElm);
192269
}
193270
} else if (BUILD.constructableCSS) {
194271
let appliedStyles = rootAppliedStyles.get(styleContainerNode);
195272
if (!appliedStyles) {
196-
rootAppliedStyles.set(styleContainerNode, (appliedStyles = new Set()));
273+
rootAppliedStyles.set(styleContainerNode, (appliedStyles = new Map()));
197274
}
198275
if (!appliedStyles.has(scopeId)) {
199276
/**
@@ -220,12 +297,13 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
220297
styleContainerNode.adoptedStyleSheets = [...styleContainerNode.adoptedStyleSheets, stylesheet];
221298
}
222299

223-
appliedStyles.add(scopeId);
300+
appliedStyles.set(scopeId, null);
224301

225302
// Remove SSR style element from shadow root now that adoptedStyleSheets is in use
226303
// Only remove from shadow roots, not from document head (for scoped components)
227304
if (BUILD.hydrateClientSide && 'host' in styleContainerNode) {
228-
const ssrStyleElm = styleContainerNode.querySelector(`[${HYDRATED_STYLE_ID}="${scopeId}"]`);
305+
const shadowRoot = styleContainerNode as ShadowRoot;
306+
const ssrStyleElm = shadowRoot.querySelector<HTMLStyleElement>(`[${HYDRATED_STYLE_ID}="${scopeId}"]`);
229307
if (ssrStyleElm) {
230308
writeTask(() => ssrStyleElm.remove());
231309
}

0 commit comments

Comments
 (0)