Skip to content

Commit 83cbf04

Browse files
authored
Merge branch 'master' into wjh/wtr-clean-hydration
2 parents 639f754 + 16048aa commit 83cbf04

File tree

25 files changed

+434
-8
lines changed

25 files changed

+434
-8
lines changed

.github/workflows/karma.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ jobs:
5858
- run: LEGACY_BROWSERS=1 yarn sauce:ci
5959
- run: FORCE_NATIVE_SHADOW_MODE_FOR_TEST=1 yarn sauce:ci
6060
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 yarn sauce:ci
61+
- run: DISABLE_DETACHED_REHYDRATION=1 yarn sauce:ci
6162
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 DISABLE_SYNTHETIC=1 yarn sauce:ci
63+
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 DISABLE_DETACHED_REHYDRATION=1 yarn sauce:ci
6264

6365
- name: Upload coverage results
6466
uses: actions/upload-artifact@v4
@@ -187,6 +189,8 @@ jobs:
187189
- run: NODE_ENV_FOR_TEST=production yarn hydration:sauce:ci:engine-server
188190
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 yarn hydration:sauce:ci:engine-server
189191
- run: DISABLE_STATIC_CONTENT_OPTIMIZATION=1 yarn hydration:sauce:ci:engine-server
192+
- run: DISABLE_DETACHED_REHYDRATION=1 yarn hydration:sauce:ci:engine-server
193+
- run: DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 DISABLE_DETACHED_REHYDRATION=1 yarn hydration:sauce:ci:engine-server
190194
- run: yarn hydration:sauce:ci
191195
- run: ENABLE_SYNTHETIC_SHADOW_IN_HYDRATION=1 yarn hydration:sauce:ci
192196
- run: NODE_ENV_FOR_TEST=production yarn hydration:sauce:ci

packages/@lwc/engine-core/src/framework/vm.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,14 @@ function flushRehydrationQueue() {
673673
for (let i = 0, len = vms.length; i < len; i += 1) {
674674
const vm = vms[i];
675675
try {
676-
rehydrate(vm);
676+
// We want to prevent rehydration from occurring when nodes are detached from the DOM as this can trigger
677+
// unintended side effects, like lifecycle methods being called multiple times.
678+
// For backwards compatibility, we use a flag to control the check.
679+
// 1. When flag is off, always rehydrate (legacy behavior)
680+
// 2. When flag is on, only rehydrate when the VM state is connected (fixed behavior)
681+
if (!lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION || vm.state === VMState.connected) {
682+
rehydrate(vm);
683+
}
677684
} catch (error) {
678685
if (i + 1 < len) {
679686
// pieces of the queue are still pending to be rehydrated, those should have priority

packages/@lwc/features/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const features: FeatureFlagMap = {
2323
DISABLE_SCOPE_TOKEN_VALIDATION: null,
2424
LEGACY_LOCKER_ENABLED: null,
2525
DISABLE_LEGACY_VALIDATION: null,
26+
DISABLE_DETACHED_REHYDRATION: null,
2627
};
2728

2829
if (!(globalThis as any).lwcRuntimeFlags) {

packages/@lwc/features/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export interface FeatureFlagMap {
9393
* If false or unset, then the value of the `LEGACY_LOCKER_ENABLED` flag is used.
9494
*/
9595
DISABLE_LEGACY_VALIDATION: FeatureFlagValue;
96+
97+
/**
98+
* If true, skips rehydration of DOM elements that are not connected.
99+
* Applies to rehydration performed while flushing the rehydration queue.
100+
*/
101+
DISABLE_DETACHED_REHYDRATION: FeatureFlagValue;
96102
}
97103

98104
export type FeatureFlagName = keyof FeatureFlagMap;

packages/@lwc/integration-not-karma/configs/base.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const env = {
1616
'ENGINE_SERVER',
1717
'FORCE_NATIVE_SHADOW_MODE_FOR_TEST',
1818
'NATIVE_SHADOW',
19+
'DISABLE_DETACHED_REHYDRATION',
1920
]),
2021
LWC_VERSION,
2122
NODE_ENV: options.NODE_ENV_FOR_TEST,
@@ -56,7 +57,10 @@ export default {
5657
<script type="module">
5758
globalThis.process = ${JSON.stringify({ env })};
5859
globalThis.lwcRuntimeFlags = ${JSON.stringify(
59-
pluck(options, ['DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE'])
60+
pluck(options, [
61+
'DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE',
62+
'DISABLE_DETACHED_REHYDRATION',
63+
])
6064
)};
6165
6266
${maybeImport('@lwc/synthetic-shadow', !options.DISABLE_SYNTHETIC)}

packages/@lwc/integration-not-karma/helpers/options.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export const DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE = Boolean(
3030
process.env.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE
3131
);
3232

33+
export const DISABLE_DETACHED_REHYDRATION = Boolean(process.env.DISABLE_DETACHED_REHYDRATION);
34+
3335
export const ENGINE_SERVER = Boolean(process.env.ENGINE_SERVER);
3436

3537
// --- Test config --- //
@@ -55,6 +57,7 @@ export const COVERAGE_DIR_FOR_OPTIONS =
5557
NODE_ENV_FOR_TEST,
5658
DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE,
5759
ENGINE_SERVER,
60+
DISABLE_DETACHED_REHYDRATION,
5861
})
5962
.filter(([, val]) => val)
6063
.map(([key, val]) => `${key}=${val}`)

packages/@lwc/integration-not-karma/test/component/lifecycle-callbacks/index.spec.js

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import TimingParentLight from 'timing/parentLight';
1111
import ReorderingList from 'reordering/list';
1212
import ReorderingListLight from 'reordering/listLight';
1313
import Details from 'x/details';
14+
import MutationsParent from 'mutations/parent';
15+
import MutationsParentLight from 'mutations/parentLight';
16+
17+
import { extractDataIds } from 'test-utils';
1418

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

321325
document.body.appendChild(elm);
322-
expect(window.timingBuffer).toEqual(
323-
!lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE
324-
? ['parent:connectedCallback', 'child:connectedCallback']
325-
: ['parent:connectedCallback']
326-
);
326+
327+
const expected = ['parent:connectedCallback'];
328+
329+
if (lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION) {
330+
expected.push('parent:renderedCallback');
331+
}
332+
333+
if (!lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
334+
expected.push('child:connectedCallback');
335+
}
336+
337+
expect(window.timingBuffer).toEqual(expected);
327338
});
328339
});
329340
});
@@ -436,3 +447,118 @@ describe('attributeChangedCallback', () => {
436447
expect(details.getAttribute('open')).toBeNull();
437448
});
438449
});
450+
451+
describe('child mutations - scheduled rehydration', () => {
452+
const scenarios = [
453+
{
454+
testName: 'shadow',
455+
tagName: 'mutations-parent',
456+
Ctor: MutationsParent,
457+
},
458+
{
459+
testName: 'light',
460+
tagName: 'mutations-parent-light',
461+
Ctor: MutationsParentLight,
462+
},
463+
];
464+
465+
scenarios.forEach(({ testName, tagName, Ctor }) => {
466+
describe(testName, () => {
467+
it('connect', async () => {
468+
const elm = createElement(tagName, { is: Ctor });
469+
document.body.appendChild(elm);
470+
471+
expect(window.timingBuffer).toEqual([
472+
'parent:connectedCallback',
473+
'child1:connectedCallback',
474+
'grand:child1:connectedCallback',
475+
'grand:child1:renderedCallback',
476+
'child1:renderedCallback',
477+
'parent:renderedCallback',
478+
]);
479+
});
480+
481+
it('connect/mutate-child', async () => {
482+
const elm = createElement(tagName, { is: Ctor });
483+
document.body.appendChild(elm);
484+
resetTimingBuffer();
485+
486+
const ids = extractDataIds(elm);
487+
ids.child1.addChild();
488+
489+
await Promise.resolve();
490+
491+
expect(window.timingBuffer).toEqual([
492+
'grand:child2:connectedCallback',
493+
'grand:child2:renderedCallback',
494+
'child1:renderedCallback',
495+
]);
496+
});
497+
498+
it('connect/mutate-child/disconnect-child', async () => {
499+
const elm = createElement(tagName, { is: Ctor });
500+
document.body.appendChild(elm);
501+
resetTimingBuffer();
502+
503+
const ids = extractDataIds(elm);
504+
// Mutate child - grand child 2
505+
ids.child1.addChild();
506+
// Disconnect the child that was just mutated
507+
elm.disconnectLastChild();
508+
509+
await Promise.resolve();
510+
511+
const expected = [
512+
'child1:disconnectedCallback',
513+
'grand:child1:disconnectedCallback',
514+
'parent:renderedCallback',
515+
];
516+
517+
if (
518+
lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE &&
519+
!lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION
520+
) {
521+
// These are fired in the children of the disconnected child
522+
expected.push(
523+
'grand:child2:connectedCallback',
524+
'grand:child2:renderedCallback'
525+
);
526+
}
527+
528+
expect(window.timingBuffer).toEqual(expected);
529+
});
530+
531+
it('connect/disconnect-child/mutate-child', async () => {
532+
const elm = createElement(tagName, { is: Ctor });
533+
document.body.appendChild(elm);
534+
resetTimingBuffer();
535+
536+
const ids = extractDataIds(elm);
537+
elm.disconnectLastChild();
538+
// Mutate child that was just disconnected
539+
ids.child1.addChild();
540+
541+
await Promise.resolve();
542+
543+
const expected = [
544+
'child1:disconnectedCallback',
545+
'grand:child1:disconnectedCallback',
546+
'parent:renderedCallback',
547+
];
548+
549+
if (
550+
lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE &&
551+
!lwcRuntimeFlags.DISABLE_DETACHED_REHYDRATION
552+
) {
553+
// These are fired in the children of the disconnected child
554+
expected.push(
555+
'grand:child2:connectedCallback',
556+
'grand:child2:renderedCallback'
557+
);
558+
}
559+
560+
expect(window.timingBuffer).toEqual(expected);
561+
});
562+
});
563+
});
564+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<template for:each={children} for:item="child">
3+
<mutations-grand-child key={child.uid} uid={child.uid}></mutations-grand-child>
4+
</template>
5+
</template>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { LightningElement, api } from 'lwc';
2+
3+
export default class extends LightningElement {
4+
@api uid;
5+
@api children = [{ uid: '1' }];
6+
connectedCallback() {
7+
window.timingBuffer.push(`child${this.uid}:connectedCallback`);
8+
}
9+
renderedCallback() {
10+
window.timingBuffer.push(`child${this.uid}:renderedCallback`);
11+
}
12+
disconnectedCallback() {
13+
// This component could get disconnected by our Karma `test-setup.js` after `window.timingBuffer` has
14+
// already been cleared; we don't care about the `disconnectedCallback`s in that case.
15+
if (window.timingBuffer) {
16+
window.timingBuffer.push(`child${this.uid}:disconnectedCallback`);
17+
}
18+
}
19+
@api
20+
addChild() {
21+
this.children = [...this.children, { uid: `${this.children.length + 1}` }];
22+
}
23+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template lwc:render-mode="light">
2+
<template for:each={children} for:item="child">
3+
<mutations-grand-child key={child.uid} uid={child.uid}></mutations-grand-child>
4+
</template>
5+
</template>

0 commit comments

Comments
 (0)