Compare commits

...
2 changed files with 228 additions and 8 deletions
+28 -7
View File
@@ -55,6 +55,13 @@ export class HassRouterPage extends ReactiveElement {
private _currentLoadProm?: Promise<void>;
// True while a route change is loading and the outgoing panel (or a loading
// screen) is still shown, waiting to be replaced. While true we don't forward
// property updates, because they are meant for the incoming panel. It stays
// false when the new panel is shown immediately (no loading screen), so that
// panel keeps receiving updates while its module finishes loading.
private _replacingPanel = false;
private _panelReady = new PanelReady();
private _cache = {};
@@ -79,9 +86,9 @@ export class HassRouterPage extends ReactiveElement {
}
if (!changedProps.has("route")) {
// Do not update if we have a currentLoadProm, because that means
// that there is still an old panel shown and we're moving to a new one.
if (this.lastChild && !this._currentLoadProm) {
// Skip while the outgoing panel is still shown for a pending route
// change; the update is meant for the incoming panel, not this one.
if (this.lastChild && !this._replacingPanel) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
@@ -185,9 +192,15 @@ export class HassRouterPage extends ReactiveElement {
// It will be automatically upgraded when loading done.
if (!routerOptions.showLoading) {
const loadComplete = () => {
this._currentLoadProm = undefined;
// Ignore a stale load that resolves after a newer navigation took over.
if (this._currentPage === newPage) {
this._currentLoadProm = undefined;
}
};
this._currentLoadProm = loadProm.then(loadComplete, loadComplete);
// The new panel is shown right away, so keep forwarding updates to it
// while its module loads.
this._replacingPanel = false;
this._createPanel(routerOptions, newPage, routeOptions);
return;
}
@@ -195,6 +208,9 @@ export class HassRouterPage extends ReactiveElement {
// We are only going to show the loading screen after some time.
// That way we won't have a double fast flash on fast connections.
let created = false;
// The outgoing panel stays shown until the new one has loaded; don't
// forward updates to it in the meantime.
this._replacingPanel = true;
this._showLoadingScreenTimeout = window.setTimeout(() => {
if (created || this._currentPage !== newPage) {
@@ -210,11 +226,11 @@ export class HassRouterPage extends ReactiveElement {
this._currentLoadProm = loadProm.then(
() => {
this._currentLoadProm = undefined;
// Check if we're still trying to show the same page.
// Ignore a stale load that resolves after a newer navigation took over.
if (this._currentPage !== newPage) {
return;
}
this._currentLoadProm = undefined;
created = true;
this._createPanel(
@@ -223,9 +239,14 @@ export class HassRouterPage extends ReactiveElement {
// @ts-ignore TS forgot this is not a string.
routeOptions
);
// The new panel is now shown; resume forwarding updates to it.
this._replacingPanel = false;
},
() => {
this._currentLoadProm = undefined;
if (this._currentPage === newPage) {
this._currentLoadProm = undefined;
this._replacingPanel = false;
}
}
);
}
+200 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReactiveElement } from "lit";
import { state } from "lit/decorators";
import { property, state } from "lit/decorators";
import type { RouterOptions } from "../../src/layouts/hass-router-page";
import { HassRouterPage } from "../../src/layouts/hass-router-page";
import { panelIsReady } from "../../src/layouts/panel-ready";
@@ -36,9 +36,99 @@ const loadedPanelImport = new Promise<void>((resolve) => {
class LoadedPanel extends HTMLElement {}
let resolvePropPanelImport: () => void;
const propPanelImport = new Promise<void>((resolve) => {
resolvePropPanelImport = resolve;
});
class PropPanel extends HTMLElement {
public value = 0;
}
// A router that shows its panel immediately (no `showLoading`) and forwards a
// property to it on every update, mirroring how the config and profile panels
// forward `hass` to their child sections.
class PropRouter extends HassRouterPage {
@property({ attribute: false }) public value = 0;
protected routerOptions: RouterOptions = {
routes: {
slow: { tag: "test-prop-panel", load: () => propPanelImport },
},
};
protected updatePageEl(el: PropPanel) {
el.value = this.value;
}
}
let resolveSecondPanelImport: () => void;
const secondPanelImport = new Promise<void>((resolve) => {
resolveSecondPanelImport = resolve;
});
class SwapFirstPanel extends HTMLElement {
public value = 0;
}
class SwapSecondPanel extends HTMLElement {
public value = 0;
}
// A router that shows a loading screen between routes, so the outgoing panel
// stays visible while the next one loads.
class SwapRouter extends HassRouterPage {
@property({ attribute: false }) public value = 0;
protected routerOptions: RouterOptions = {
showLoading: true,
routes: {
first: { tag: "test-swap-first-panel" },
second: { tag: "test-swap-second-panel", load: () => secondPanelImport },
},
};
protected updatePageEl(el: SwapFirstPanel | SwapSecondPanel) {
el.value = this.value;
}
}
let resolveRaceAlphaImport: () => void;
const raceAlphaImport = new Promise<void>((resolve) => {
resolveRaceAlphaImport = resolve;
});
let resolveRaceBetaImport: () => void;
const raceBetaImport = new Promise<void>((resolve) => {
resolveRaceBetaImport = resolve;
});
// A router where both routes have to load their module, used to check that a
// stale load resolving late does not stop `pageRendered` from waiting for the
// current route.
class RaceRouter extends HassRouterPage {
protected routerOptions: RouterOptions = {
showLoading: true,
routes: {
alpha: { tag: "test-race-alpha-panel", load: () => raceAlphaImport },
beta: { tag: "test-race-beta-panel", load: () => raceBetaImport },
},
};
public get rendered() {
return this.pageRendered;
}
}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
customElements.define("test-prop-router", PropRouter);
customElements.define("test-prop-panel", PropPanel);
customElements.define("test-swap-router", SwapRouter);
customElements.define("test-swap-first-panel", SwapFirstPanel);
customElements.define("test-swap-second-panel", SwapSecondPanel);
customElements.define("test-race-router", RaceRouter);
let router: TestRouter | undefined;
@@ -101,11 +191,120 @@ describe("HassRouterPage panel readiness", () => {
});
});
describe("HassRouterPage update propagation during load", () => {
it("forwards updates to a panel shown while its module is still loading", async () => {
const element = document.createElement("test-prop-router") as PropRouter;
element.route = { prefix: "", path: "/slow" };
document.body.append(element);
await element.updateComplete;
const panel = element.lastElementChild as PropPanel;
expect(panel).toBeInstanceOf(PropPanel);
expect(panel.value).toBe(0);
// A new value (e.g. an updated `hass` carrying freshly loaded fragment
// translations) arrives while the panel module is still loading. The panel
// is already shown, so the update must reach it right away rather than
// being skipped as if an outgoing panel were still being replaced.
element.value = 1;
await element.updateComplete;
expect(panel.value).toBe(1);
// Finishing the load must not lose the value it received in the meantime.
resolvePropPanelImport();
await propPanelImport;
await element.updateComplete;
expect(panel.value).toBe(1);
element.remove();
});
it("does not forward updates to the outgoing panel while a new one loads", async () => {
const element = document.createElement("test-swap-router") as SwapRouter;
element.route = { prefix: "", path: "/first" };
document.body.append(element);
// A router with a loading screen creates the panel once its load resolves.
await vi.waitFor(() => {
expect(element.lastElementChild).toBeInstanceOf(SwapFirstPanel);
});
const first = element.lastElementChild as SwapFirstPanel;
expect(first.value).toBe(0);
// Navigate to a page whose module is still loading. The first panel stays
// shown in the meantime (the loading screen only appears after a delay).
element.route = { prefix: "", path: "/second" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
// An update now is meant for the incoming panel, so it must not leak to the
// outgoing one that is about to be replaced.
element.value = 1;
await element.updateComplete;
expect(first.value).toBe(0);
// Once the new panel is shown, updates resume normally.
resolveSecondPanelImport();
await vi.waitFor(() => {
expect(element.lastElementChild).toBeInstanceOf(SwapSecondPanel);
});
const second = element.lastElementChild as SwapSecondPanel;
element.value = 2;
await element.updateComplete;
expect(second.value).toBe(2);
element.remove();
});
it("keeps waiting for the current panel's load when a stale load resolves", async () => {
const element = document.createElement("test-race-router") as RaceRouter;
element.route = { prefix: "", path: "/alpha" };
document.body.append(element);
await element.updateComplete;
// Navigate to beta while alpha's module is still loading.
element.route = { prefix: "", path: "/beta" };
await element.updateComplete;
// The stale alpha load resolves late. It must not clear the tracking for
// the current (beta) load, or `pageRendered` would resolve too early.
resolveRaceAlphaImport();
customElements.define(
"test-race-alpha-panel",
class extends HTMLElement {}
);
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
let betaReady = false;
element.rendered.then(() => {
betaReady = true;
});
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(betaReady).toBe(false);
// Beta finishing loading resolves it.
resolveRaceBetaImport();
customElements.define("test-race-beta-panel", class extends HTMLElement {});
await expect(element.rendered).resolves.toBeUndefined();
element.remove();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
"test-immediate-panel": ImmediatePanel;
"test-deferred-panel": DeferredPanel;
"test-loaded-panel": LoadedPanel;
"test-prop-router": PropRouter;
"test-prop-panel": PropPanel;
"test-swap-router": SwapRouter;
"test-swap-first-panel": SwapFirstPanel;
"test-swap-second-panel": SwapSecondPanel;
"test-race-router": RaceRouter;
}
}