Skip to content

Commit 6bf65f4

Browse files
saudademjjclaude
andcommitted
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 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 05d12e5 commit 6bf65f4

5 files changed

Lines changed: 525 additions & 53 deletions

File tree

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: 116 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,58 @@ 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
124+
const trackedElm = appliedStyles.get(scopeId);
125+
if (trackedElm !== undefined) {
126+
if (trackedElm === null) {
127+
// Applied via constructable stylesheet — immune to external DOM removal
128+
return scopeId;
129+
}
130+
if (trackedElm.isConnected) {
131+
// Style element is still in the DOM — update content during HMR only
132+
if (BUILD.hotModuleReplacement && trackedElm.textContent !== styleText) {
133+
trackedElm.textContent = styleText;
134+
}
135+
return scopeId;
136+
}
137+
// Style element was removed from the DOM by an external framework
138+
// (e.g. SvelteKit head management). Remove stale tracking so we re-apply.
139+
appliedStyles.delete(scopeId);
77140
}
78141

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}"]`);
142+
// For shadow roots without constructable stylesheets, check if a shared
143+
// style container was removed and needs to be re-inserted
144+
if ('host' in appliedStyleContainer) {
145+
const sharedContainer = rootAppliedSharedStyleContainers.get(appliedStyleContainer as ShadowRoot);
146+
if (sharedContainer && !sharedContainer.isConnected) {
147+
appliedStyleContainer.insertBefore(sharedContainer, appliedStyleContainer.firstChild);
148+
appliedStyles.set(scopeId, sharedContainer);
149+
return scopeId;
150+
}
151+
}
152+
153+
// Check for existing hydrated or HMR style element already in the DOM
154+
const existingStyleElm =
155+
(((BUILD.hydrateClientSide || BUILD.hotModuleReplacement) &&
156+
appliedStyleContainer.querySelector<HTMLStyleElement>(`[${HYDRATED_STYLE_ID}="${scopeId}"]`)) ||
157+
undefined);
85158

86159
if (existingStyleElm) {
87160
// 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;
161+
existingStyleElm.textContent = styleText;
162+
appliedStyles.set(scopeId, existingStyleElm);
163+
} else {
164+
const styleElm = win.document.createElement('style');
165+
styleElm.textContent = styleText;
166+
let trackedStyleElm: HTMLStyleElement | null = styleElm;
92167

93168
// Apply CSP nonce to the style tag if it exists
94169
const nonce = plt.$nonce$ ?? queryNonceMetaTagContent(win.document);
@@ -109,21 +184,21 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
109184
* attach styles at the end of the head tag if we render scoped components
110185
*/
111186
if (!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation)) {
112-
if (styleContainerNode.nodeName === 'HEAD') {
187+
if (appliedStyleContainer.nodeName === 'HEAD') {
113188
/**
114189
* if the page contains preconnect links, we want to insert the styles
115190
* after the last preconnect link to ensure the styles are preloaded
116191
*/
117-
const preconnectLinks = styleContainerNode.querySelectorAll('link[rel=preconnect]');
192+
const preconnectLinks = appliedStyleContainer.querySelectorAll('link[rel=preconnect]');
118193
const referenceNode =
119194
preconnectLinks.length > 0
120195
? preconnectLinks[preconnectLinks.length - 1].nextSibling
121-
: styleContainerNode.querySelector('style');
122-
(styleContainerNode as HTMLElement).insertBefore(
196+
: appliedStyleContainer.querySelector('style');
197+
(appliedStyleContainer as HTMLElement).insertBefore(
123198
styleElm,
124-
referenceNode?.parentNode === styleContainerNode ? referenceNode : null,
199+
referenceNode?.parentNode === appliedStyleContainer ? referenceNode : null,
125200
);
126-
} else if ('host' in styleContainerNode) {
201+
} else if ('host' in appliedStyleContainer) {
127202
if (supportsConstructableStylesheets) {
128203
/**
129204
* If a scoped component is used within a shadow root then turn the styles into a
@@ -135,19 +210,22 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
135210
* Note: constructable stylesheets can't be shared between windows,
136211
* we need to create a new one for the current window if necessary
137212
*/
138-
const currentWindow = styleContainerNode.defaultView ?? styleContainerNode.ownerDocument.defaultView;
139-
const stylesheet = new currentWindow.CSSStyleSheet();
140-
stylesheet.replaceSync(style);
213+
const stylesheet = createShadowRootConstructableStylesheet(
214+
appliedStyleContainer as ShadowRoot,
215+
styleText,
216+
);
141217

142218
/**
143219
* > If the array needs to be modified, use in-place mutations like push().
144220
* https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
145221
*/
146222
if (supportsMutableAdoptedStyleSheets) {
147-
styleContainerNode.adoptedStyleSheets.unshift(stylesheet);
223+
appliedStyleContainer.adoptedStyleSheets.unshift(stylesheet);
148224
} else {
149-
styleContainerNode.adoptedStyleSheets = [stylesheet, ...styleContainerNode.adoptedStyleSheets];
225+
appliedStyleContainer.adoptedStyleSheets = [stylesheet, ...appliedStyleContainer.adoptedStyleSheets];
150226
}
227+
228+
trackedStyleElm = null;
151229
} else {
152230
/**
153231
* If a scoped component is used within a shadow root and constructable stylesheets are
@@ -162,38 +240,34 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
162240
* During HMR, create separate style elements for scoped components so they can be
163241
* updated independently without affecting other components' styles.
164242
*/
165-
const existingStyleContainer: HTMLStyleElement = styleContainerNode.querySelector('style');
243+
const existingStyleContainer = appliedStyleContainer.querySelector<HTMLStyleElement>('style');
166244
if (existingStyleContainer && !BUILD.hotModuleReplacement) {
167-
existingStyleContainer.textContent = style + existingStyleContainer.textContent;
245+
existingStyleContainer.textContent = styleText + existingStyleContainer.textContent;
246+
existingStyleContainer.removeAttribute(HYDRATED_STYLE_ID);
247+
rootAppliedSharedStyleContainers.set(appliedStyleContainer as ShadowRoot, existingStyleContainer);
248+
trackedStyleElm = existingStyleContainer;
168249
} else {
169-
(styleContainerNode as HTMLElement).prepend(styleElm);
250+
appliedStyleContainer.insertBefore(styleElm, appliedStyleContainer.firstChild);
170251
}
171252
}
172253
} else {
173-
styleContainerNode.append(styleElm);
254+
appliedStyleContainer.append(styleElm);
174255
}
175256
}
176257

177258
/**
178259
* attach styles at the beginning of a shadow root node if we render shadow components
179260
*/
180261
if (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) {
181-
styleContainerNode.insertBefore(styleElm, null);
262+
appliedStyleContainer.insertBefore(styleElm, null);
182263
}
183264

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-
}
265+
appliedStyles.set(scopeId, trackedStyleElm);
192266
}
193267
} else if (BUILD.constructableCSS) {
194268
let appliedStyles = rootAppliedStyles.get(styleContainerNode);
195269
if (!appliedStyles) {
196-
rootAppliedStyles.set(styleContainerNode, (appliedStyles = new Set()));
270+
rootAppliedStyles.set(styleContainerNode, (appliedStyles = new Map()));
197271
}
198272
if (!appliedStyles.has(scopeId)) {
199273
/**
@@ -220,12 +294,13 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
220294
styleContainerNode.adoptedStyleSheets = [...styleContainerNode.adoptedStyleSheets, stylesheet];
221295
}
222296

223-
appliedStyles.add(scopeId);
297+
appliedStyles.set(scopeId, null);
224298

225299
// Remove SSR style element from shadow root now that adoptedStyleSheets is in use
226300
// Only remove from shadow roots, not from document head (for scoped components)
227301
if (BUILD.hydrateClientSide && 'host' in styleContainerNode) {
228-
const ssrStyleElm = styleContainerNode.querySelector(`[${HYDRATED_STYLE_ID}="${scopeId}"]`);
302+
const shadowRoot = styleContainerNode as ShadowRoot;
303+
const ssrStyleElm = shadowRoot.querySelector<HTMLStyleElement>(`[${HYDRATED_STYLE_ID}="${scopeId}"]`);
229304
if (ssrStyleElm) {
230305
writeTask(() => ssrStyleElm.remove());
231306
}

0 commit comments

Comments
 (0)