Skip to content
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
4 changes: 4 additions & 0 deletions .github/workflows/karma.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ jobs:
- run: LEGACY_BROWSERS=1 yarn sauce:ci
- run: FORCE_NATIVE_SHADOW_MODE_FOR_TEST=1 yarn sauce:ci
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 yarn sauce:ci
- run: DISABLE_DETACHED_REHYDRATION=1 yarn sauce:ci
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 DISABLE_SYNTHETIC=1 yarn sauce:ci
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 DISABLE_DETACHED_REHYDRATION=1 yarn sauce:ci

Comment on lines +61 to 64
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding test configs for when we can re-enable integration tests in CI.

- name: Upload coverage results
uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -187,6 +189,8 @@ jobs:
- run: NODE_ENV_FOR_TEST=production yarn hydration:sauce:ci:engine-server
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 yarn hydration:sauce:ci:engine-server
- run: DISABLE_STATIC_CONTENT_OPTIMIZATION=1 yarn hydration:sauce:ci:engine-server
- run: DISABLE_DETACHED_REHYDRATION=1 yarn hydration:sauce:ci:engine-server
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 DISABLE_DETACHED_REHYDRATION=1 yarn hydration:sauce:ci:engine-server
- run: yarn hydration:sauce:ci
- run: ENABLE_SYNTHETIC_SHADOW_IN_HYDRATION=1 yarn hydration:sauce:ci
- run: NODE_ENV_FOR_TEST=production yarn hydration:sauce:ci
Expand Down
9 changes: 8 additions & 1 deletion packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,14 @@ function flushRehydrationQueue() {
for (let i = 0, len = vms.length; i < len; i += 1) {
const vm = vms[i];
try {
rehydrate(vm);
// We want to prevent rehydration from occurring when nodes are detached from the DOM as this can trigger
// unintended side effects, like lifecycle methods being called multiple times.
// For backwards compatibility, we use a flag to control the check.
// 1. When flag is disabled always rehydrate (legacy behavior)
// 2. When flag is enabled only rehydrate when the VM state is connected (fixed behavior)
if (!lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION || vm.state === VMState.connected) {
rehydrate(vm);
}
Comment on lines +676 to +683
Copy link
Member Author

@jmsjtu jmsjtu Sep 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the entire change, we just need to check that the vm is connected before rehydrating.

Times when the vm is disconnected before hydration occur during async race conditions.

Example scenario:

VM2 is scheduled for rehydration due to update from wire adapter.
VM1, grand-owner of VM2, is scheduled for rehydration.
VM1 rehydration goes first due to idx-sorting.
VM1 rendering disconnects VM2 from the DOM.
VM2 disconnectedCallback fires.
VM2 rehydration triggers, calling connectedCallback and render for entire disconnected subtree.

This will become a more apparent issue with the introduction of state managers, as the state manger can trigger reactivity asynchronously from outside of the component.

This issue has occurred frequently for team using redux.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@abdulsattar can you please verify if this will have an effect of HMR?

I see some usages of scheduleRehydration in the hot-swaps code.

} catch (error) {
if (i + 1 < len) {
// pieces of the queue are still pending to be rehydrated, those should have priority
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/features/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const features: FeatureFlagMap = {
DISABLE_SCOPE_TOKEN_VALIDATION: null,
LEGACY_LOCKER_ENABLED: null,
DISABLE_LEGACY_VALIDATION: null,
DISABLE_DETACHED_REHYDRATION: null,
};

if (!(globalThis as any).lwcRuntimeFlags) {
Expand Down
6 changes: 6 additions & 0 deletions packages/@lwc/features/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export interface FeatureFlagMap {
* If false or unset, then the value of the `LEGACY_LOCKER_ENABLED` flag is used.
*/
DISABLE_LEGACY_VALIDATION: FeatureFlagValue;

/**
* If true, skips rehydration of DOM elements that are not connected.
* Applies to rehydration performed while flushing the rehydration queue.
*/
DISABLE_DETACHED_REHYDRATION: FeatureFlagValue;
}

export type FeatureFlagName = keyof FeatureFlagMap;
6 changes: 5 additions & 1 deletion packages/@lwc/integration-not-karma/configs/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const env = {
'ENGINE_SERVER',
'FORCE_NATIVE_SHADOW_MODE_FOR_TEST',
'NATIVE_SHADOW',
'DISABLE_DETACHED_REHYDRATION',
]),
LWC_VERSION,
NODE_ENV: options.NODE_ENV_FOR_TEST,
Expand Down Expand Up @@ -61,7 +62,10 @@ export default {
<script type="module">
globalThis.process = ${JSON.stringify({ env })};
globalThis.lwcRuntimeFlags = ${JSON.stringify(
pluck(options, ['DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE'])
pluck(options, [
'DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE',
'DISABLE_DETACHED_REHYDRATION',
])
)};

${maybeImport('@lwc/synthetic-shadow', !options.DISABLE_SYNTHETIC)}
Expand Down
3 changes: 3 additions & 0 deletions packages/@lwc/integration-not-karma/helpers/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE = Boolean(
process.env.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE
);

export const DISABLE_DETACHED_REHYDRATION = Boolean(process.env.DISABLE_DETACHED_REHYDRATION);

export const ENGINE_SERVER = Boolean(process.env.ENGINE_SERVER);

// --- Test config --- //
Expand Down Expand Up @@ -62,6 +64,7 @@ export const COVERAGE_DIR_FOR_OPTIONS =
NODE_ENV_FOR_TEST,
DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE,
ENGINE_SERVER,
DISABLE_DETACHED_REHYDRATION,
})
.filter(([, val]) => val)
.map(([key, val]) => `${key}=${val}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import TimingParentLight from 'timing/parentLight';
import ReorderingList from 'reordering/list';
import ReorderingListLight from 'reordering/listLight';
import Details from 'x/details';
import MutationsParent from 'mutations/parent';
import MutationsParentLight from 'mutations/parentLight';

import { extractDataIds } from 'test-utils';

function resetTimingBuffer() {
window.timingBuffer = [];
Expand Down Expand Up @@ -319,11 +323,18 @@ describe('connectedCallback/renderedCallback timing when reconnected', () => {
resetTimingBuffer();

document.body.appendChild(elm);
expect(window.timingBuffer).toEqual(
!lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE
? ['parent:connectedCallback', 'child:connectedCallback']
: ['parent:connectedCallback']
);

const expected = ['parent:connectedCallback'];

if (lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION) {
expected.push('parent:renderedCallback');
}

if (!lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
expected.push('child:connectedCallback');
}
Comment on lines +327 to +335
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changed because previously, both the parent and child were being rehydrated while they were both disconnected from the DOM.

When the elements are re-inserted into the DOM, the VMs are not longer marked as dirty so rehydration is skipped.

The renderedCallback did not fire previously because the parent element was not connected to the DOM.


expect(window.timingBuffer).toEqual(expected);
});
});
});
Expand Down Expand Up @@ -436,3 +447,118 @@ describe('attributeChangedCallback', () => {
expect(details.getAttribute('open')).toBeNull();
});
});

describe('child mutations - scheduled rehydration', () => {
const scenarios = [
{
testName: 'shadow',
tagName: 'mutations-parent',
Ctor: MutationsParent,
},
{
testName: 'light',
tagName: 'mutations-parent-light',
Ctor: MutationsParentLight,
},
];

scenarios.forEach(({ testName, tagName, Ctor }) => {
describe(testName, () => {
it('connect', async () => {
const elm = createElement(tagName, { is: Ctor });
document.body.appendChild(elm);

expect(window.timingBuffer).toEqual([
'parent:connectedCallback',
'child1:connectedCallback',
'grand:child1:connectedCallback',
'grand:child1:renderedCallback',
'child1:renderedCallback',
'parent:renderedCallback',
]);
});

it('connect/mutate-child', async () => {
const elm = createElement(tagName, { is: Ctor });
document.body.appendChild(elm);
resetTimingBuffer();

const ids = extractDataIds(elm);
ids.child1.addChild();

await Promise.resolve();

expect(window.timingBuffer).toEqual([
'grand:child2:connectedCallback',
'grand:child2:renderedCallback',
'child1:renderedCallback',
]);
});

it('connect/mutate-child/disconnect-child', async () => {
const elm = createElement(tagName, { is: Ctor });
document.body.appendChild(elm);
resetTimingBuffer();

const ids = extractDataIds(elm);
// Mutate child - grand child 2
ids.child1.addChild();
// Disconnect the child that was just mutated
elm.disconnectLastChild();

await Promise.resolve();

const expected = [
'child1:disconnectedCallback',
'grand:child1:disconnectedCallback',
'parent:renderedCallback',
];

if (
lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE &&
!lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION
) {
// These are fired in the children of the disconnected child
expected.push(
'grand:child2:connectedCallback',
'grand:child2:renderedCallback'
);
}

expect(window.timingBuffer).toEqual(expected);
});

it('connect/disconnect-child/mutate-child', async () => {
const elm = createElement(tagName, { is: Ctor });
document.body.appendChild(elm);
resetTimingBuffer();

const ids = extractDataIds(elm);
elm.disconnectLastChild();
// Mutate child that was just disconnected
ids.child1.addChild();

await Promise.resolve();

const expected = [
'child1:disconnectedCallback',
'grand:child1:disconnectedCallback',
'parent:renderedCallback',
];

if (
lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE &&
!lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION
) {
// These are fired in the children of the disconnected child
expected.push(
'grand:child2:connectedCallback',
'grand:child2:renderedCallback'
);
}

expect(window.timingBuffer).toEqual(expected);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<template for:each={children} for:item="child">
<mutations-grand-child key={child.uid} uid={child.uid}></mutations-grand-child>
</template>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { LightningElement, api } from 'lwc';

export default class extends LightningElement {
@api uid;
@api children = [{ uid: '1' }];
connectedCallback() {
window.timingBuffer.push(`child${this.uid}:connectedCallback`);
}
renderedCallback() {
window.timingBuffer.push(`child${this.uid}:renderedCallback`);
}
disconnectedCallback() {
// This component could get disconnected by our Karma `test-setup.js` after `window.timingBuffer` has
// already been cleared; we don't care about the `disconnectedCallback`s in that case.
if (window.timingBuffer) {
window.timingBuffer.push(`child${this.uid}:disconnectedCallback`);
}
}
@api
addChild() {
this.children = [...this.children, { uid: `${this.children.length + 1}` }];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template lwc:render-mode="light">
<template for:each={children} for:item="child">
<mutations-grand-child key={child.uid} uid={child.uid}></mutations-grand-child>
</template>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { LightningElement, api } from 'lwc';

export default class extends LightningElement {
static renderMode = 'light';
@api uid;
@api children = [{ uid: '1' }];
connectedCallback() {
window.timingBuffer.push(`child${this.uid}:connectedCallback`);
}
renderedCallback() {
window.timingBuffer.push(`child${this.uid}:renderedCallback`);
}
disconnectedCallback() {
// This component could get disconnected by our Karma `test-setup.js` after `window.timingBuffer` has
// already been cleared; we don't care about the `disconnectedCallback`s in that case.
if (window.timingBuffer) {
window.timingBuffer.push(`child${this.uid}:disconnectedCallback`);
}
}
@api
addChild() {
this.children = [...this.children, { uid: `${this.children.length + 1}` }];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<template></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { LightningElement, api } from 'lwc';

export default class extends LightningElement {
@api uid;
connectedCallback() {
window.timingBuffer.push(`grand:child${this.uid}:connectedCallback`);
}
renderedCallback() {
window.timingBuffer.push(`grand:child${this.uid}:renderedCallback`);
}
disconnectedCallback() {
// This component could get disconnected by our Karma `test-setup.js` after `window.timingBuffer` has
// already been cleared; we don't care about the `disconnectedCallback`s in that case.
if (window.timingBuffer) {
window.timingBuffer.push(`grand:child${this.uid}:disconnectedCallback`);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<template lwc:render-mode="light"></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { LightningElement, api } from 'lwc';

export default class extends LightningElement {
static renderMode = 'light';
@api uid;
connectedCallback() {
window.timingBuffer.push(`grand:child${this.uid}:connectedCallback`);
}
renderedCallback() {
window.timingBuffer.push(`grand:child${this.uid}:renderedCallback`);
}
disconnectedCallback() {
// This component could get disconnected by our Karma `test-setup.js` after `window.timingBuffer` has
// already been cleared; we don't care about the `disconnectedCallback`s in that case.
if (window.timingBuffer) {
window.timingBuffer.push(`grand:child${this.uid}:disconnectedCallback`);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<template for:each={children} for:item="child">
<mutations-child key={child.uid} uid={child.uid} data-id={child.name}></mutations-child>
</template>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { api, LightningElement } from 'lwc';

export default class extends LightningElement {
@api children = [{ uid: '1', name: 'child1' }];
connectedCallback() {
window.timingBuffer.push('parent:connectedCallback');
}
renderedCallback() {
window.timingBuffer.push('parent:renderedCallback');
}
disconnectedCallback() {
// This component could get disconnected by our Karma `test-setup.js` after `window.timingBuffer` has
// already been cleared; we don't care about the `disconnectedCallback`s in that case.
if (window.timingBuffer) {
window.timingBuffer.push('parent:disconnectedCallback');
}
}
@api
addChild() {
const uid = this.children.length + 1;
this.children = [...this.children, { uid, name: `child${uid}` }];
}
@api
disconnectLastChild() {
this.children = this.children.slice(0, this.children.length - 1);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template lwc:render-mode="light">
<template for:each={children} for:item="child">
<mutations-child key={child.uid} uid={child.uid} data-id={child.name}></mutations-child>
</template>
</template>
Loading