From e3b156694356287b9cd9149661f9fda7f5202a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Mon, 28 Mar 2022 14:34:30 +0200 Subject: [PATCH] [IMP] component: wait for parent rendering to be complete before doing child This is a breaking semantic change. With this commit, the UI is frozen whenever owl is waiting for a parent to change Also, this allows Owl not to render components that will be removed later. --- src/component/component_node.ts | 30 +- src/component/fibers.ts | 58 +++ src/component/scheduler.ts | 9 + .../__snapshots__/concurrency.test.ts.snap | 178 ++++++++ .../__snapshots__/reactivity.test.ts.snap | 26 ++ tests/components/concurrency.test.ts | 409 ++++++++++++++++-- tests/components/reactivity.test.ts | 46 +- 7 files changed, 696 insertions(+), 60 deletions(-) diff --git a/src/component/component_node.ts b/src/component/component_node.ts index 17a0af398..5f7c3a12b 100644 --- a/src/component/component_node.ts +++ b/src/component/component_node.ts @@ -2,23 +2,16 @@ import type { App, Env } from "../app/app"; import { BDom, VNode } from "../blockdom"; import { clearReactivesForCallback, + getSubscriptions, + NonReactive, Reactive, reactive, TARGET, - NonReactive, - getSubscriptions, } from "../reactivity"; import { batched, Callback } from "../utils"; import { Component, ComponentConstructor } from "./component"; import { fibersInError, handleError } from "./error_handling"; -import { - Fiber, - makeChildFiber, - makeRootFiber, - MountFiber, - MountOptions, - RootFiber, -} from "./fibers"; +import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers"; import { applyDefaultProps } from "./props_validation"; import { STATUS } from "./status"; @@ -126,6 +119,7 @@ export function component

( node.initiateRender(new Fiber(node, parentFiber)); } + parentFiber.root!.reachedChildren.add(node); return node; } @@ -193,7 +187,7 @@ export class ComponentNode

implements VNode implements VNode implements VNode register in followup + let prev = this.root!.node; + let current = prev.parent; + while (current) { + if (current.fiber) { + let root = current.fiber.root!; + if (root.counter) { + root.delayedRenders.push(this); + return; + } else { + if (!root.reachedChildren.has(prev)) { + // is dead + this.node.app.scheduler.shouldClear = true; + return; + } + current = root.node; + } + } + prev = current; + current = current.parent; + } + + // there are no current rendering from above => we can render + this._render(); + } + + _render() { + const node = this.node; + const root = this.root; + if (root) { + try { + this.bdom = node.renderFn(); + root.setCounter(root.counter - 1); + } catch (e) { + handleError({ node, error: e }); + } + } + } } export class RootFiber extends Fiber { @@ -94,6 +141,9 @@ export class RootFiber extends Fiber { // i.e.: render triggered in onWillUnmount or in willPatch will be delayed locked: boolean = false; + delayedRenders: Fiber[] = []; + reachedChildren: WeakSet = new WeakSet(); + complete() { const node = this.node; this.locked = true; @@ -148,6 +198,14 @@ export class RootFiber extends Fiber { setCounter(newValue: number) { this.counter = newValue; if (newValue === 0) { + if (this.delayedRenders.length) { + for (let f of this.delayedRenders) { + if (f.root) { + f.render(); + } + } + this.delayedRenders = []; + } this.node.app.scheduler.flush(); } } diff --git a/src/component/scheduler.ts b/src/component/scheduler.ts index e20bdc222..6f12ce1a3 100644 --- a/src/component/scheduler.ts +++ b/src/component/scheduler.ts @@ -13,6 +13,7 @@ export class Scheduler { tasks: Set = new Set(); requestAnimationFrame: Window["requestAnimationFrame"]; frame: number = 0; + shouldClear: boolean = false; constructor() { this.requestAnimationFrame = Scheduler.requestAnimationFrame; @@ -31,6 +32,14 @@ export class Scheduler { this.frame = this.requestAnimationFrame(() => { this.frame = 0; this.tasks.forEach((fiber) => this.processFiber(fiber)); + if (this.shouldClear) { + this.shouldClear = false; + for (let task of this.tasks) { + if (task.node.status === STATUS.DESTROYED) { + this.tasks.delete(task); + } + } + } }); } } diff --git a/tests/components/__snapshots__/concurrency.test.ts.snap b/tests/components/__snapshots__/concurrency.test.ts.snap index a64aee4ef..b47d739a4 100644 --- a/tests/components/__snapshots__/concurrency.test.ts.snap +++ b/tests/components/__snapshots__/concurrency.test.ts.snap @@ -1067,6 +1067,184 @@ exports[`delay willUpdateProps with rendering grandchild 4`] = ` }" `; +exports[`delayed rendering, but then initial rendering is cancelled by yet another render 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return component(\`B\`, {value: ctx['state'].value}, key + \`__1\`, node, ctx); + } +}" +`; + +exports[`delayed rendering, but then initial rendering is cancelled by yet another render 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return component(\`C\`, {value: ctx['state'].someValue+ctx['props'].value}, key + \`__1\`, node, ctx); + } +}" +`; + +exports[`delayed rendering, but then initial rendering is cancelled by yet another render 3`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + let block3 = createBlock(\`

\`); + + return function template(ctx, node, key = \\"\\") { + const b2 = component(\`D\`, {}, key + \`__1\`, node, ctx); + let txt1 = ctx['props'].value; + const b3 = block3([txt1]); + return multi([b2, b3]); + } +}" +`; + +exports[`delayed rendering, but then initial rendering is cancelled by yet another render 4`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let hdlr1 = [ctx['increment'], ctx]; + let txt1 = ctx['state'].val; + return block1([hdlr1, txt1]); + } +}" +`; + +exports[`delayed rendering, reusing fiber and stuff 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return component(\`B\`, {value: ctx['state'].value}, key + \`__1\`, node, ctx); + } +}" +`; + +exports[`delayed rendering, reusing fiber and stuff 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + const b2 = text(ctx['props'].value); + const b3 = component(\`C\`, {}, key + \`__1\`, node, ctx); + return multi([b2, b3]); + } +}" +`; + +exports[`delayed rendering, reusing fiber and stuff 3`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let hdlr1 = [ctx['increment'], ctx]; + let txt1 = ctx['state'].val; + return block1([hdlr1, txt1]); + } +}" +`; + +exports[`delayed rendering, reusing fiber then component is destroyed and stuff 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + let b2,b3; + b2 = text(\`A\`); + if (ctx['state'].value<15) { + b3 = component(\`B\`, {value: ctx['state'].value}, key + \`__1\`, node, ctx); + } + return multi([b2, b3]); + } +}" +`; + +exports[`delayed rendering, reusing fiber then component is destroyed and stuff 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + const b2 = text(ctx['props'].value); + const b3 = component(\`C\`, {}, key + \`__1\`, node, ctx); + return multi([b2, b3]); + } +}" +`; + +exports[`delayed rendering, reusing fiber then component is destroyed and stuff 3`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let hdlr1 = [ctx['increment'], ctx]; + let txt1 = ctx['state'].val; + return block1([hdlr1, txt1]); + } +}" +`; + +exports[`delayed rendering, then component is destroyed and stuff 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return component(\`B\`, {value: ctx['state'].value}, key + \`__1\`, node, ctx); + } +}" +`; + +exports[`delayed rendering, then component is destroyed and stuff 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + let b2,b3; + b2 = text(ctx['props'].value); + if (ctx['props'].value<10) { + b3 = component(\`C\`, {}, key + \`__1\`, node, ctx); + } + return multi([b2, b3]); + } +}" +`; + +exports[`delayed rendering, then component is destroyed and stuff 3`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let hdlr1 = [ctx['increment'], ctx]; + let txt1 = ctx['state'].val; + return block1([hdlr1, txt1]); + } +}" +`; + exports[`destroying/recreating a subcomponent, other scenario 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/components/__snapshots__/reactivity.test.ts.snap b/tests/components/__snapshots__/reactivity.test.ts.snap index c6aec89e9..29166b0cc 100644 --- a/tests/components/__snapshots__/reactivity.test.ts.snap +++ b/tests/components/__snapshots__/reactivity.test.ts.snap @@ -1,5 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`reactivity in lifecycle Child component doesn't render when state they depend on changes but their parent is about to unmount them 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + let b2; + if (ctx['state'].renderChild) { + b2 = component(\`Child\`, {state: ctx['state']}, key + \`__1\`, node, ctx); + } + return multi([b2]); + } +}" +`; + +exports[`reactivity in lifecycle Child component doesn't render when state they depend on changes but their parent is about to unmount them 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return text(ctx['props'].state.content.a); + } +}" +`; + exports[`reactivity in lifecycle can use a state hook 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/components/concurrency.test.ts b/tests/components/concurrency.test.ts index 2525fd8fb..e55ea6631 100644 --- a/tests/components/concurrency.test.ts +++ b/tests/components/concurrency.test.ts @@ -3,6 +3,7 @@ import { Component, mount, onMounted, + onRendered, onWillStart, onWillUnmount, onWillUpdateProps, @@ -1079,11 +1080,7 @@ test("concurrent renderings scenario 3", async () => { stateC.fromC = "d"; await nextTick(); expect(fixture.innerHTML).toBe("

1c

"); - expect([ - "ComponentC:willRender", - "ComponentD:willUpdateProps", - "ComponentC:rendered", - ]).toBeLogged(); + expect([]).toBeLogged(); defB.resolve(); // resolve rendering initiated in A (still blocked in D) await nextTick(); @@ -1099,14 +1096,7 @@ test("concurrent renderings scenario 3", async () => { defsD[0].resolve(); // resolve rendering initiated in C (should be ignored) await nextTick(); - expect(ComponentD.prototype.someValue).toBeCalledTimes(1); - expect(fixture.innerHTML).toBe("

1c

"); - expect([]).toBeLogged(); - - defsD[1].resolve(); // completely resolve rendering initiated in A - await nextTick(); expect(fixture.innerHTML).toBe("

2d

"); - expect(ComponentD.prototype.someValue).toBeCalledTimes(2); expect([ "ComponentD:willRender", "ComponentD:rendered", @@ -1119,6 +1109,7 @@ test("concurrent renderings scenario 3", async () => { "ComponentB:patched", "ComponentA:patched", ]).toBeLogged(); + expect(ComponentD.prototype.someValue).toBeCalledTimes(2); }); test("concurrent renderings scenario 4", async () => { @@ -1207,11 +1198,7 @@ test("concurrent renderings scenario 4", async () => { stateC.fromC = "d"; await nextTick(); expect(fixture.innerHTML).toBe("

1c

"); - expect([ - "ComponentC:willRender", - "ComponentD:willUpdateProps", - "ComponentC:rendered", - ]).toBeLogged(); + expect([]).toBeLogged(); defB.resolve(); // resolve rendering initiated in A (still blocked in D) await nextTick(); @@ -1227,6 +1214,12 @@ test("concurrent renderings scenario 4", async () => { defsD[1].resolve(); // completely resolve rendering initiated in A await nextTick(); + expect(fixture.innerHTML).toBe("

1c

"); + expect(ComponentD.prototype.someValue).toBeCalledTimes(1); + expect([]).toBeLogged(); + + defsD[0].resolve(); // resolve rendering initiated in C (should be ignored) + await nextTick(); expect(fixture.innerHTML).toBe("

2d

"); expect(ComponentD.prototype.someValue).toBeCalledTimes(2); expect([ @@ -1241,12 +1234,6 @@ test("concurrent renderings scenario 4", async () => { "ComponentB:patched", "ComponentA:patched", ]).toBeLogged(); - - defsD[0].resolve(); // resolve rendering initiated in C (should be ignored) - await nextTick(); - expect(fixture.innerHTML).toBe("

2d

"); - expect(ComponentD.prototype.someValue).toBeCalledTimes(2); - expect([]).toBeLogged(); }); test("concurrent renderings scenario 5", async () => { @@ -2714,13 +2701,7 @@ test("delay willUpdateProps", async () => { parent.render(); await nextTick(); expect(fixture.innerHTML).toBe("0_0"); - expect([ - "Child:willRender", - "Child:rendered", - "Parent:willRender", - "Child:willUpdateProps", - "Parent:rendered", - ]).toBeLogged(); + expect(["Parent:willRender", "Child:willUpdateProps", "Parent:rendered"]).toBeLogged(); promise = makeDeferred(); const prom2 = promise; @@ -2837,13 +2818,9 @@ test("delay willUpdateProps with rendering grandchild", async () => { await nextTick(); expect(fixture.innerHTML).toBe("0_0
"); expect([ - "DelayedChild:willRender", - "DelayedChild:rendered", "GrandParent:willRender", "Parent:willUpdateProps", "GrandParent:rendered", - "ReactiveChild:willRender", - "ReactiveChild:rendered", "Parent:willRender", "DelayedChild:willUpdateProps", "ReactiveChild:willUpdateProps", @@ -2864,8 +2841,6 @@ test("delay willUpdateProps with rendering grandchild", async () => { "GrandParent:willRender", "Parent:willUpdateProps", "GrandParent:rendered", - "ReactiveChild:willRender", - "ReactiveChild:rendered", "Parent:willRender", "DelayedChild:willUpdateProps", "ReactiveChild:willUpdateProps", @@ -3254,6 +3229,368 @@ test("rendering parent twice, with different props on child and stuff", async () ]).toBeLogged(); }); +test("delayed rendering, but then initial rendering is cancelled by yet another render", async () => { + const promC = makeDeferred(); + let stateB: any = null; + + class D extends Component { + static template = xml``; + state = useState({ val: 1 }); + setup() { + useLogLifecycle(); + } + increment() { + this.state.val++; + } + } + + class C extends Component { + static template = xml`

`; + static components = { D }; + setup() { + useLogLifecycle(); + onWillUpdateProps(() => promC); + } + } + + class B extends Component { + static template = xml``; + static components = { C }; + state = useState({ someValue: 3 }); + setup() { + useLogLifecycle(); + stateB = this.state; + } + } + + class A extends Component { + static template = xml``; + static components = { B }; + state = useState({ value: 33 }); + setup() { + useLogLifecycle(); + } + } + + const parent = await mount(A, fixture); + expect(fixture.innerHTML).toBe("

36

"); + expect([ + "A:setup", + "A:willStart", + "A:willRender", + "B:setup", + "B:willStart", + "A:rendered", + "B:willRender", + "C:setup", + "C:willStart", + "B:rendered", + "C:willRender", + "D:setup", + "D:willStart", + "C:rendered", + "D:willRender", + "D:rendered", + "D:mounted", + "C:mounted", + "B:mounted", + "A:mounted", + ]).toBeLogged(); + + // update B and C, but render is blocked by C willupdateProps + stateB.someValue = 5; + await nextTick(); + expect(["B:willRender", "C:willUpdateProps", "B:rendered"]).toBeLogged(); + + // update D => render should be delayed, because B is currently rendering + fixture.querySelector("button")!.click(); + await nextTick(); + expect([]).toBeLogged(); + + // update A => render should go to B and cancel it + parent.state.value = 34; + await nextTick(); + expect([ + "A:willRender", + "B:willUpdateProps", + "A:rendered", + "B:willRender", + "C:willUpdateProps", + "B:rendered", + ]).toBeLogged(); + + promC.resolve(); + await nextTick(); + expect([ + "C:willRender", + "C:rendered", + "D:willRender", + "D:rendered", + "D:willPatch", + "D:patched", + "A:willPatch", + "B:willPatch", + "C:willPatch", + "C:patched", + "B:patched", + "A:patched", + ]).toBeLogged(); + expect(fixture.innerHTML).toBe("

39

"); +}); + +test("delayed rendering, reusing fiber and stuff", async () => { + let prom1 = makeDeferred(); + let prom2 = makeDeferred(); + + class C extends Component { + static template = xml``; + state = useState({ val: 1 }); + setup() { + useLogLifecycle(); + } + increment() { + this.state.val++; + } + } + + class B extends Component { + static template = xml``; + static components = { C }; + setup() { + useLogLifecycle(); + let flag = false; + onWillUpdateProps(() => { + flag = true; + return prom1; + }); + onRendered(async () => { + if (flag) { + await nextMicroTick(); + prom2.resolve(); + } + }); + } + } + + class A extends Component { + static template = xml``; + static components = { B }; + state = useState({ value: 33 }); + setup() { + useLogLifecycle(); + } + } + + const parent = await mount(A, fixture); + expect(fixture.innerHTML).toBe("33"); + expect([ + "A:setup", + "A:willStart", + "A:willRender", + "B:setup", + "B:willStart", + "A:rendered", + "B:willRender", + "C:setup", + "C:willStart", + "B:rendered", + "C:willRender", + "C:rendered", + "C:mounted", + "B:mounted", + "A:mounted", + ]).toBeLogged(); + + // initiate a render in A, but is blocked in B + parent.state.value = 34; + await nextTick(); + expect(["A:willRender", "B:willUpdateProps", "A:rendered"]).toBeLogged(); + + // initiate a render in C => delayed because of render in A + fixture.querySelector("button")!.click(); + await nextTick(); + expect([]).toBeLogged(); + + // wait for render in A to be completed + prom1.resolve(); + await prom2; + expect(["B:willRender", "B:rendered", "C:willRender", "C:rendered"]).toBeLogged(); + + // initiate a new render in A => fiber will be reused + parent.state.value = 355; + await nextTick(); + expect(fixture.innerHTML).toBe("355"); + expect([ + "A:willRender", + "B:willUpdateProps", + "A:rendered", + "B:willRender", + "B:rendered", + "A:willPatch", + "B:willPatch", + "B:patched", + "A:patched", + "C:willPatch", + "C:patched", + ]).toBeLogged(); +}); + +test("delayed rendering, then component is destroyed and stuff", async () => { + let prom1 = makeDeferred(); + + class C extends Component { + static template = xml``; + state = useState({ val: 1 }); + setup() { + useLogLifecycle(); + } + increment() { + this.state.val++; + } + } + + class B extends Component { + static template = xml``; + static components = { C }; + setup() { + useLogLifecycle(); + onWillUpdateProps(() => prom1); + } + } + + class A extends Component { + static template = xml``; + static components = { B }; + state = useState({ value: 3 }); + setup() { + useLogLifecycle(); + } + } + + const parent = await mount(A, fixture); + expect(fixture.innerHTML).toBe("3"); + expect([ + "A:setup", + "A:willStart", + "A:willRender", + "B:setup", + "B:willStart", + "A:rendered", + "B:willRender", + "C:setup", + "C:willStart", + "B:rendered", + "C:willRender", + "C:rendered", + "C:mounted", + "B:mounted", + "A:mounted", + ]).toBeLogged(); + + // initiate a render in C (so will be first task) + fixture.querySelector("button")!.click(); + // initiate a render in A, but is blocked in B. the render will destroy c. also, + // it blocks the render C + parent.state.value = 34; + await nextTick(); + expect(["A:willRender", "B:willUpdateProps", "A:rendered"]).toBeLogged(); + + // wait for render in A to be completed + prom1.resolve(); + await nextTick(); + expect(fixture.innerHTML).toBe("34"); + expect([ + "B:willRender", + "B:rendered", + "A:willPatch", + "B:willPatch", + "C:willUnmount", + "C:willDestroy", + "B:patched", + "A:patched", + ]).toBeLogged(); + + await nextTick(); +}); + +test("delayed rendering, reusing fiber then component is destroyed and stuff", async () => { + let prom1 = makeDeferred(); + + class C extends Component { + static template = xml``; + state = useState({ val: 1 }); + setup() { + useLogLifecycle(); + } + increment() { + this.state.val++; + } + } + + class B extends Component { + static template = xml``; + static components = { C }; + setup() { + useLogLifecycle(); + onWillUpdateProps(() => prom1); + } + } + + class A extends Component { + static template = xml`A`; + static components = { B }; + state = useState({ value: 3 }); + setup() { + useLogLifecycle(); + } + } + + const parent = await mount(A, fixture); + expect(fixture.innerHTML).toBe("A3"); + expect([ + "A:setup", + "A:willStart", + "A:willRender", + "B:setup", + "B:willStart", + "A:rendered", + "B:willRender", + "C:setup", + "C:willStart", + "B:rendered", + "C:willRender", + "C:rendered", + "C:mounted", + "B:mounted", + "A:mounted", + ]).toBeLogged(); + + // initiate a render in A, but is blocked in B + parent.state.value = 5; + await nextTick(); + expect(["A:willRender", "B:willUpdateProps", "A:rendered"]).toBeLogged(); + + // initiate a render in C (will be delayed because of render in A) + fixture.querySelector("button")!.click(); + await nextTick(); + expect([]).toBeLogged(); + + // initiate a render in A, that will destroy B + parent.state.value = 23; + await nextTick(); + expect(fixture.innerHTML).toBe("A"); + expect([ + "A:willRender", + "A:rendered", + "A:willPatch", + "B:willUnmount", + "C:willUnmount", + "C:willDestroy", + "B:willDestroy", + "A:patched", + ]).toBeLogged(); +}); + // test.skip("components with shouldUpdate=false", async () => { // const state = { p: 1, cc: 10 }; diff --git a/tests/components/reactivity.test.ts b/tests/components/reactivity.test.ts index a987ef5a2..997eef124 100644 --- a/tests/components/reactivity.test.ts +++ b/tests/components/reactivity.test.ts @@ -8,7 +8,7 @@ import { useState, xml, } from "../../src"; -import { makeTestFixture, nextTick, snapshotEverything } from "../helpers"; +import { makeTestFixture, nextTick, snapshotEverything, useLogLifecycle } from "../helpers"; let fixture: HTMLElement; @@ -155,4 +155,48 @@ describe("reactivity in lifecycle", () => { expect(steps).toEqual([2]); expect(fixture.innerHTML).toBe("
2
"); }); + + test("Child component doesn't render when state they depend on changes but their parent is about to unmount them", async () => { + class Child extends Component { + static template = xml``; + setup() { + useLogLifecycle(); + } + } + class Parent extends Component { + static template = xml``; + static components = { Child }; + state: any = useState({ renderChild: true, content: { a: 2 } }); + setup() { + useLogLifecycle(); + } + } + + const parent = await mount(Parent, fixture); + expect(fixture.innerHTML).toBe("2"); + expect([ + "Parent:setup", + "Parent:willStart", + "Parent:willRender", + "Child:setup", + "Child:willStart", + "Parent:rendered", + "Child:willRender", + "Child:rendered", + "Child:mounted", + "Parent:mounted", + ]).toBeLogged(); + + parent.state.content = null; + parent.state.renderChild = false; + await nextTick(); + expect([ + "Parent:willRender", + "Parent:rendered", + "Parent:willPatch", + "Child:willUnmount", + "Child:willDestroy", + "Parent:patched", + ]).toBeLogged(); + }); });