From 3da3399d93fc5de6210775f3ea38236a3b553799 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 14 Oct 2026 21:30:32 +0800 Subject: [PATCH 1/4] fix: open the What's New page only once its feed has loaded An automatic open after a minor upgrade used to create the panel first and fetch the changelog feed afterwards. On a machine where altimate.ai is blocked (corporate proxy or firewall, no DNS, slow link) the page then opened straight into its error state, and the failure was logged and reported as an error event under the command name. - `checkAndShowOnActivation` now fetches the manifest before opening; when the fetch fails and nothing is cached the page stays closed, and the fetched manifest is served to the page so it does not fetch twice. - A failed fetch is reported as `whats_new_feed_unavailable` with a reason class (`http_`, `timeout`, `dns`, `proxy`, `network`, `tls`, `other`), the trigger and whether the cached manifest stood in; the message itself is not sent. - The page's manifest request is answered directly, so a blocked feed is a failed response rendered as the existing offline state, not a `dbtTerminal.error`. - Debug log lines when the page opens and when its manifest is served, so a support log shows what happened. Tests: trigger suite covers the closed-when-unreachable and fetch-first paths (both fail on master); a new suite covers the reason classes, the failed response, the cache fallback and the pre-fetched manifest. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- src/dbtPowerUserExtension.ts | 3 +- src/telemetry/events.ts | 5 + src/test/suite/whatsNewFeed.test.ts | 241 +++++++++++++++++++++++++ src/test/suite/whatsNewTrigger.test.ts | 78 ++++++-- src/webview_provider/whatsNewPanel.ts | 145 +++++++++++++-- 5 files changed, 438 insertions(+), 34 deletions(-) create mode 100644 src/test/suite/whatsNewFeed.test.ts diff --git a/src/dbtPowerUserExtension.ts b/src/dbtPowerUserExtension.ts index 7308f49e4..318bf6a9d 100644 --- a/src/dbtPowerUserExtension.ts +++ b/src/dbtPowerUserExtension.ts @@ -190,7 +190,8 @@ export class DBTPowerUserExtension implements Disposable { await this.mcpServer.updateMcpExtensionApi(); this.dbtProjectContainer.setContext(context); this.dbtProjectContainer.initializeWalkthrough(); - this.whatsNewPanel.checkAndShowOnActivation(); + // Settles once the open decision is made; it never rejects. + void this.whatsNewPanel.checkAndShowOnActivation(); await this.dbtProjectContainer.detectDBT(); await this.dbtProjectContainer.initializeDBTProjects(); await this.statusBars.initialize(); diff --git a/src/telemetry/events.ts b/src/telemetry/events.ts index 7fdc210db..e2316d0b3 100644 --- a/src/telemetry/events.ts +++ b/src/telemetry/events.ts @@ -78,6 +78,11 @@ export enum TelemetryEvents { "AltimateCode/TroubleshootCodeActionClick" = "AltimateCode/TroubleshootCodeActionClick", "DocumentationEditor/UnitTestGenerateClick" = "DocumentationEditor/UnitTestGenerateClick", "WhatsNew/PageOpened" = "whats_new_page_opened", + // The changelog feed could not be fetched: `reason` is a class (http_403, + // timeout, dns, proxy, network, tls, other), `fallback` says whether a cached + // manifest stood in. Not an error event: it is the user's network, and an + // automatic open is skipped rather than shown broken. + "WhatsNew/FeedUnavailable" = "whats_new_feed_unavailable", // Carries a `source` so the three changelog links (topbar, hero CTA, footer, // plus the error state) can be told apart. "WhatsNew/FullChangelogClicked" = "full_changelog_clicked", diff --git a/src/test/suite/whatsNewFeed.test.ts b/src/test/suite/whatsNewFeed.test.ts new file mode 100644 index 000000000..a798bacfa --- /dev/null +++ b/src/test/suite/whatsNewFeed.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import fetch from "node-fetch"; +import { + WhatsNewPanel, + feedFailureReason, +} from "../../webview_provider/whatsNewPanel"; + +const MANIFEST_CACHE_KEY = "whatsNew.cachedManifest"; + +const FEED = ` +Tue, 14 Oct 2026 10:00:00 GMT +[Feature] Fetch before openingdbt-power-user +https://altimate.ai/changelog#fetchMon, 13 Oct 2026 10:00:00 GMT +desc +`; + +/** + * Builds the panel with only the collaborators the manifest path touches; + * node-fetch is the jest-mapped mock, so every network outcome can be + * replayed. The response the page receives is captured through + * sendResponseToWebview. + */ +const manifestHarness = (opts: { trigger?: "auto" | "manual" } = {}) => { + const store = new Map(); + const responses: Record[] = []; + const telemetry = { + sendTelemetryEvent: jest.fn(), + sendTelemetryError: jest.fn(), + }; + const dbtTerminal = { debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const panel = Object.create(WhatsNewPanel.prototype) as Record< + string, + unknown + > & { + serveManifest: (id: string) => Promise; + fetchManifest: (trigger: "auto" | "manual") => Promise; + }; + panel.dbtProjectContainer = { + extensionVersion: "0.66.0", + getFromGlobalState: (key: string) => store.get(key), + setToGlobalState: (key: string, value: unknown): void => { + store.set(key, value); + }, + }; + panel.telemetry = telemetry; + panel.dbtTerminal = dbtTerminal; + panel.currentTrigger = opts.trigger ?? "manual"; + panel.sendResponseToWebview = (r: Record) => { + responses.push(r); + }; + return { panel, store, responses, telemetry, dbtTerminal }; +}; + +// jest.config.js maps node-fetch to src/test/mock/node-fetch.ts (a jest.fn). +const fetchMock = fetch as unknown as jest.Mock< + (...args: unknown[]) => Promise +>; +const okFeed = () => + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => FEED, + }); +const httpStatus = (status: number) => + fetchMock.mockResolvedValue({ ok: false, status, text: async () => "" }); +const networkDown = () => + fetchMock.mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: { code: "ECONNREFUSED" }, + }), + ); + +describe("feedFailureReason", () => { + it.each([ + [ + Object.assign(new Error("This operation was aborted"), { + name: "AbortError", + }), + "timeout", + ], + [new Error("Feed request failed with 403"), "http_403"], + [new Error("Feed request failed with 503"), "http_503"], + [ + new Error( + "request to https://x failed, reason: getaddrinfo ENOTFOUND altimate.ai", + ), + "dns", + ], + [ + new Error( + "request to https://x failed, reason: unable to verify the first certificate", + ), + "tls", + ], + [ + Object.assign(new TypeError("fetch failed"), { + cause: { code: "SELF_SIGNED_CERT_IN_CHAIN" }, + }), + "tls", + ], + [ + new Error("request to https://x failed, reason: read ECONNRESET"), + "network", + ], + [ + new Error( + "request to https://x failed, reason: Failed to establish a socket connection to proxies: PROXY 10.0.0.1:8080", + ), + "proxy", + ], + [ + new Error("tunneling socket could not be established, statusCode=407"), + "proxy", + ], + [ + Object.assign(new TypeError("fetch failed"), { + cause: { code: "ECONNREFUSED" }, + }), + "network", + ], + [ + new Error( + "Client network socket disconnected before secure TLS connection was established", + ), + "network", + ], + [new Error("something else entirely"), "other"], + ["not even an error", "other"], + ])("%s -> %s", (error, reason) => { + expect(feedFailureReason(error)).toBe(reason); + }); +}); + +describe("WhatsNewPanel manifest path", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("serves a fetched manifest, caches it and reports the page open", async () => { + okFeed(); + const h = manifestHarness({ trigger: "manual" }); + + await h.panel.serveManifest("req-1"); + + expect(h.responses).toHaveLength(1); + expect(h.responses[0]).toMatchObject({ + syncRequestId: "req-1", + status: true, + }); + const data = h.responses[0].data as { items: unknown[]; version: string }; + expect(data.items).toHaveLength(1); + expect(data.version).toBe("0.66.0"); + expect(h.store.get(MANIFEST_CACHE_KEY)).toBe(data); + expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith( + "whats_new_page_opened", + { version: "0.66.0", trigger: "manual" }, + { items_shown: 1 }, + ); + expect(h.dbtTerminal.warn).not.toHaveBeenCalled(); + }); + + it("answers a blocked feed with a failed response and a feed-unavailable event, not an error", async () => { + httpStatus(403); + const h = manifestHarness({ trigger: "manual" }); + + await h.panel.serveManifest("req-2"); + + expect(h.responses[0]).toMatchObject({ + syncRequestId: "req-2", + status: false, + error: "Feed request failed with 403", + }); + expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith( + "whats_new_feed_unavailable", + { reason: "http_403", trigger: "manual", fallback: "none" }, + ); + expect(h.telemetry.sendTelemetryError).not.toHaveBeenCalled(); + expect(h.dbtTerminal.error).not.toHaveBeenCalled(); + expect(h.dbtTerminal.warn).toHaveBeenCalledTimes(1); + expect(String(h.dbtTerminal.warn.mock.calls[0][1])).toContain("(http_403)"); + }); + + it("falls back to the cached manifest when the network is down, and says so", async () => { + networkDown(); + const h = manifestHarness({ trigger: "auto" }); + const cached = { + product: "dbt-power-user", + version: "0.65.0", + items: [{ title: "old" }], + }; + h.store.set(MANIFEST_CACHE_KEY, cached); + + await h.panel.serveManifest("req-3"); + + expect(h.responses[0]).toMatchObject({ status: true, data: cached }); + expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith( + "whats_new_feed_unavailable", + { reason: "network", trigger: "auto", fallback: "cache" }, + ); + expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith( + "whats_new_page_opened", + { version: "0.65.0", trigger: "auto" }, + { items_shown: 1 }, + ); + expect(String(h.dbtTerminal.warn.mock.calls[0][1])).toContain( + "showing the cached manifest", + ); + }); + + it("serves the manifest fetched before an automatic open without fetching again", async () => { + const h = manifestHarness({ trigger: "auto" }); + const preloaded = { + product: "dbt-power-user", + version: "0.66.0", + items: [], + }; + h.panel.preloaded = preloaded; + + await h.panel.serveManifest("req-4"); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(h.responses[0]).toMatchObject({ status: true, data: preloaded }); + expect(h.panel.preloaded).toBeUndefined(); + }); + + it("a timed-out feed is classed as a timeout", async () => { + fetchMock.mockRejectedValue( + Object.assign(new Error("This operation was aborted"), { + name: "AbortError", + }), + ); + const h = manifestHarness(); + + await expect(h.panel.fetchManifest("auto")).rejects.toThrow("aborted"); + expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith( + "whats_new_feed_unavailable", + { reason: "timeout", trigger: "auto", fallback: "none" }, + ); + }); +}); diff --git a/src/test/suite/whatsNewTrigger.test.ts b/src/test/suite/whatsNewTrigger.test.ts index 6de1fb687..86f42105d 100644 --- a/src/test/suite/whatsNewTrigger.test.ts +++ b/src/test/suite/whatsNewTrigger.test.ts @@ -10,9 +10,12 @@ const LAST_SEEN_VERSION_KEY = "whatsNew.lastSeenVersion"; * activation persists the current version whether or not the page opened, so * each manual reload consumes the trigger and the next attempt tests nothing. */ -const activationHarness = (opts: { enabled?: boolean } = {}) => { +const activationHarness = ( + opts: { enabled?: boolean; feedDown?: boolean } = {}, +) => { const store = new Map(); const shown: string[] = []; + const manifest = { product: "dbt-power-user", version: "x", items: [] }; const container = { extensionVersion: "0.0.0", @@ -27,9 +30,18 @@ const activationHarness = (opts: { enabled?: boolean } = {}) => { const panel = Object.create(WhatsNewPanel.prototype) as Record< string, unknown - > & { checkAndShowOnActivation: () => void }; + > & { checkAndShowOnActivation: () => Promise }; panel.dbtProjectContainer = container; panel.show = (trigger: string) => shown.push(trigger); + // The automatic open waits for the feed; a failed fetch is already logged + // and reported inside fetchManifest, so here it only rejects. + const fetchManifest = jest.fn(async () => { + if (opts.feedDown) { + throw new Error("Feed request failed with 403"); + } + return manifest; + }); + panel.fetchManifest = fetchManifest; (workspace.getConfiguration as jest.Mock).mockReturnValue({ get: (_key: string, fallback: boolean) => opts.enabled ?? fallback, @@ -37,12 +49,15 @@ const activationHarness = (opts: { enabled?: boolean } = {}) => { return { shown, + manifest, + fetchManifest, + preloaded: () => panel.preloaded, lastSeen: () => store.get(LAST_SEEN_VERSION_KEY), setLastSeen: (v: string) => store.set(LAST_SEEN_VERSION_KEY, v), /** Simulate the extension activating while reporting `version`. */ - activateAs: (version: string) => { + activateAs: async (version: string) => { container.extensionVersion = version; - panel.checkAndShowOnActivation(); + await panel.checkAndShowOnActivation(); }, }; }; @@ -52,56 +67,89 @@ describe("WhatsNewPanel.checkAndShowOnActivation", () => { jest.clearAllMocks(); }); - it("stays silent on a fresh install but records the version", () => { + it("stays silent on a fresh install but records the version", async () => { const h = activationHarness(); - h.activateAs("0.62.1"); + await h.activateAs("0.62.1"); // A first-time user gets the setup walkthrough, not a changelog for // changes they were never around for. expect(h.shown).toEqual([]); expect(h.lastSeen()).toBe("0.62.1"); }); - it("opens once on a minor upgrade, then stays silent on reload", () => { + it("opens once on a minor upgrade, then stays silent on reload", async () => { const h = activationHarness(); h.setLastSeen("0.62.1"); - h.activateAs("0.63.0"); + await h.activateAs("0.63.0"); expect(h.shown).toEqual(["auto"]); expect(h.lastSeen()).toBe("0.63.0"); // Reloading the window must not re-open the page. - h.activateAs("0.63.0"); + await h.activateAs("0.63.0"); + expect(h.shown).toEqual(["auto"]); + }); + + it("keeps the page closed when the feed is unreachable and nothing is cached", async () => { + // Upgrading on a network that blocks the feed must not greet the user with + // an error page; the version is still recorded so the next upgrade is a + // fresh decision, not a backlog. + const h = activationHarness({ feedDown: true }); + h.setLastSeen("0.62.1"); + + await h.activateAs("0.63.0"); + expect(h.fetchManifest).toHaveBeenCalledWith("auto"); + expect(h.shown).toEqual([]); + expect(h.preloaded()).toBeUndefined(); + expect(h.lastSeen()).toBe("0.63.0"); + }); + + it("fetches before opening and keeps the manifest for the page", async () => { + const h = activationHarness(); + h.setLastSeen("0.62.1"); + + await h.activateAs("0.63.0"); + expect(h.fetchManifest).toHaveBeenCalledTimes(1); expect(h.shown).toEqual(["auto"]); + expect(h.preloaded()).toBe(h.manifest); + }); + + it("does not touch the feed when there is nothing to open", async () => { + const h = activationHarness(); + h.setLastSeen("0.62.1"); + + await h.activateAs("0.62.2"); + expect(h.fetchManifest).not.toHaveBeenCalled(); + expect(h.shown).toEqual([]); }); - it("replays a realistic release sequence, opening only on minor bumps", () => { + it("replays a realistic release sequence, opening only on minor bumps", async () => { const h = activationHarness(); h.setLastSeen("0.62.1"); for (const version of ["0.62.2", "0.62.3", "0.63.0", "0.63.1", "1.0.0"]) { - h.activateAs(version); + await h.activateAs(version); } expect(h.shown).toEqual(["auto", "auto"]); expect(h.lastSeen()).toBe("1.0.0"); }); - it("records the version even when suppressed, so no upgrade double-prompts", () => { + it("records the version even when suppressed, so no upgrade double-prompts", async () => { // The persist happens before the decision: a patch release consumes // nothing, but the stored version still advances. const h = activationHarness(); h.setLastSeen("0.62.1"); - h.activateAs("0.62.9"); + await h.activateAs("0.62.9"); expect(h.shown).toEqual([]); expect(h.lastSeen()).toBe("0.62.9"); }); - it("never opens when the user disabled the setting", () => { + it("never opens when the user disabled the setting", async () => { const h = activationHarness({ enabled: false }); h.setLastSeen("0.62.1"); - h.activateAs("0.63.0"); + await h.activateAs("0.63.0"); expect(h.shown).toEqual([]); // Still tracked, so re-enabling doesn't fire a stale backlog. expect(h.lastSeen()).toBe("0.63.0"); diff --git a/src/webview_provider/whatsNewPanel.ts b/src/webview_provider/whatsNewPanel.ts index 91824f67f..dbb8aa7a0 100644 --- a/src/webview_provider/whatsNewPanel.ts +++ b/src/webview_provider/whatsNewPanel.ts @@ -55,7 +55,8 @@ const WHATS_NEW_FEED_URL = "https://altimate.ai/changelog.rss.xml"; // Bound the feed fetch so a stalled endpoint can't hang the panel — on timeout // we abort and fall back to the cached manifest. const FEED_FETCH_TIMEOUT_MS = 10_000; -const CHANGELOG_BASE_URL = "https://altimate.ai/changelog?utm_source=dbt-power-user&utm_medium=extension"; +const CHANGELOG_BASE_URL = + "https://altimate.ai/changelog?utm_source=dbt-power-user&utm_medium=extension"; const PRODUCT_SLUG = "dbt-power-user"; // globalState keys @@ -134,11 +135,60 @@ const toAnchor = (link: string): string => { type WhatsNewTrigger = "auto" | "manual"; +/** + * Why a feed fetch failed, as a small closed set for telemetry. The message + * itself stays out of the event: it is per-machine network detail (proxy + * hosts, DNS names) that cannot be acted on in aggregate, and the class is + * what tells a client-side problem (timeouts) from the user's network + * (blocked, offline, intercepted TLS). + */ +export const feedFailureReason = (error: unknown): string => { + const err = error as + | { + name?: string; + message?: string; + cause?: { code?: string; message?: string }; + } + | undefined; + if (err?.name === "AbortError") { + return "timeout"; + } + const text = [err?.message, err?.cause?.code, err?.cause?.message] + .filter(Boolean) + .join(" "); + const status = /Feed request failed with (\d{3})/.exec(text); + if (status) { + return `http_${status[1]}`; + } + if (/getaddrinfo|ENOTFOUND|EAI_AGAIN/i.test(text)) { + return "dns"; + } + if (/certificate|CERT_|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(text)) { + return "tls"; + } + // VS Code's proxy layer ("Failed to establish a socket connection to + // proxies: PROXY host:port") and a proxy refusing the CONNECT tunnel + // ("tunneling socket could not be established, statusCode=407"). + if (/\bprox(y|ies)\b|tunneling socket/i.test(text)) { + return "proxy"; + } + if ( + /ECONN|EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|EPIPE|socket hang up|network socket|fetch failed/i.test( + text, + ) + ) { + return "network"; + } + return "other"; +}; + export class WhatsNewPanel extends AltimateWebviewProvider { public static readonly viewType = "dbtPowerUser.WhatsNew"; protected viewPath = "/whats-new"; protected panelDescription = "What's New in dbt Power User"; private currentTrigger: WhatsNewTrigger = "manual"; + /** Manifest fetched before an automatic open, served to the page once. */ + private preloaded: WhatsNewManifest | undefined; public constructor( protected dbtProjectContainer: DBTProjectContainer, @@ -171,6 +221,10 @@ export class WhatsNewPanel extends AltimateWebviewProvider { */ public show(trigger: WhatsNewTrigger): void { this.currentTrigger = trigger; + this.dbtTerminal.debug( + "whatsNew:show", + `Opening the What's New page (trigger=${trigger})`, + ); if (this._panel) { (this._panel as WebviewPanel).reveal?.(); @@ -218,8 +272,10 @@ export class WhatsNewPanel extends AltimateWebviewProvider { * Auto-open decision, run once on activation (mirrors initializeWalkthrough). * Opens only on a minor/major version bump; patch releases stay silent. * The current version is always persisted so users are not re-prompted. + * The returned promise settles once the open decision is final; it never + * rejects. */ - public checkAndShowOnActivation(): void { + public async checkAndShowOnActivation(): Promise { const current = this.dbtProjectContainer.extensionVersion; const lastSeen = this.dbtProjectContainer.getFromGlobalState( LAST_SEEN_VERSION_KEY, @@ -240,10 +296,26 @@ export class WhatsNewPanel extends AltimateWebviewProvider { } if (WhatsNewPanel.isMinorOrMajorUpgrade(lastSeen, current)) { - this.show("auto"); + await this.showWhenFeedAvailable(); } } + /** + * An automatic open waits for the manifest: a user upgrading into the panel + * on a network that blocks the feed must not meet an error page as the first + * thing after the update. The fetched manifest is kept so the page does not + * fetch a second time once it loads. + */ + private async showWhenFeedAvailable(): Promise { + try { + this.preloaded = await this.fetchManifest("auto"); + } catch { + // fetchManifest has logged the failure and reported its reason. + return; + } + this.show("auto"); + } + private static parseVersion( version: string, ): [number, number, number] | undefined { @@ -273,11 +345,7 @@ export class WhatsNewPanel extends AltimateWebviewProvider { const { command, syncRequestId } = message; switch (command) { case "getWhatsNewManifest": - this.handleSyncRequestFromWebview( - syncRequestId, - () => this.getManifestForWebview(), - command, - ); + void this.serveManifest(syncRequestId); break; default: super.handleCommand(message); @@ -285,14 +353,47 @@ export class WhatsNewPanel extends AltimateWebviewProvider { } } - private async getManifestForWebview(): Promise { - const manifest = await this.fetchManifest(); + /** + * Answers the page's manifest request. Handled here rather than through + * handleSyncRequestFromWebview because an unreachable feed is the network's + * condition, not a failure of ours: fetchManifest reports it as + * feed-unavailable, and the page renders its offline state from the failed + * response. + */ + private async serveManifest( + syncRequestId: string | undefined, + ): Promise { + let manifest: WhatsNewManifest; + const preloaded = this.preloaded !== undefined; + try { + manifest = + this.preloaded ?? (await this.fetchManifest(this.currentTrigger)); + } catch (error) { + this.sendResponseToWebview({ + command: "response", + syncRequestId, + error: error instanceof Error ? error.message : String(error), + status: false, + }); + return; + } finally { + this.preloaded = undefined; + } + this.dbtTerminal.debug( + "whatsNew:serveManifest", + `Serving ${manifest.items.length} entries to the page (${preloaded ? "fetched before opening" : "fetched on request"})`, + ); this.telemetry.sendTelemetryEvent( TelemetryEvents["WhatsNew/PageOpened"], { version: manifest.version, trigger: this.currentTrigger }, { items_shown: manifest.items.length }, ); - return manifest; + this.sendResponseToWebview({ + command: "response", + syncRequestId, + status: true, + data: manifest, + }); } /** Map the changelog feed to this product's entries, newest first. */ @@ -332,7 +433,9 @@ export class WhatsNewPanel extends AltimateWebviewProvider { }; } - private async fetchManifest(): Promise { + private async fetchManifest( + trigger: WhatsNewTrigger, + ): Promise { const version = this.dbtProjectContainer.extensionVersion; const url = WHATS_NEW_FEED_URL; @@ -360,15 +463,21 @@ export class WhatsNewPanel extends AltimateWebviewProvider { return manifest; } catch (error) { const detail = error instanceof Error ? error.message : String(error); - this.dbtTerminal.warn( - "whatsNew:fetchManifest", - `Failed to fetch What's New feed from ${url}: ${detail}`, - ); - // Offline / endpoint down: fall back to the last successfully fetched - // manifest so the panel still renders something useful. + const reason = feedFailureReason(error); + // Offline / blocked / endpoint down: fall back to the last successfully + // fetched manifest so the panel still renders something useful. const cached = this.dbtProjectContainer.getFromGlobalState( MANIFEST_CACHE_KEY, ) as WhatsNewManifest | undefined; + this.dbtTerminal.warn( + "whatsNew:fetchManifest", + `Failed to fetch What's New feed from ${url} (${reason}): ${detail}` + + (cached ? "; showing the cached manifest" : ""), + ); + this.telemetry.sendTelemetryEvent( + TelemetryEvents["WhatsNew/FeedUnavailable"], + { reason, trigger, fallback: cached ? "cache" : "none" }, + ); if (cached) { return cached; } From ea41bb0f5f50b2f1c69b3bf4b4854379c43b8f13 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 14 Oct 2026 21:55:03 +0800 Subject: [PATCH 2/4] fix: share the activation fetch with a manual open; never reject the activation check Review follow-ups: - The fetch started for an automatic open is kept as a pending promise that the page's own request awaits, so a `Show What's New` run while that fetch is in flight shares it instead of fetching again; the automatic open is then skipped because the page the user asked for is already in front. A failed fetch is dropped so a later manual open fetches afresh. - `checkAndShowOnActivation` wraps its whole body in try/catch and logs through the terminal, so the fire-and-forget call at activation cannot reject; a failing `show` also drops the pending fetch. Tests: in-flight fetch shared by a manual open (one fetch, no second open), a throwing `show`, a throwing state read. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- src/test/suite/whatsNewFeed.test.ts | 4 +- src/test/suite/whatsNewTrigger.test.ts | 85 +++++++++++++++++++++--- src/webview_provider/whatsNewPanel.ts | 89 ++++++++++++++++++-------- 3 files changed, 140 insertions(+), 38 deletions(-) diff --git a/src/test/suite/whatsNewFeed.test.ts b/src/test/suite/whatsNewFeed.test.ts index a798bacfa..c3ffa12c6 100644 --- a/src/test/suite/whatsNewFeed.test.ts +++ b/src/test/suite/whatsNewFeed.test.ts @@ -215,13 +215,13 @@ describe("WhatsNewPanel manifest path", () => { version: "0.66.0", items: [], }; - h.panel.preloaded = preloaded; + h.panel.pending = Promise.resolve(preloaded); await h.panel.serveManifest("req-4"); expect(fetchMock).not.toHaveBeenCalled(); expect(h.responses[0]).toMatchObject({ status: true, data: preloaded }); - expect(h.panel.preloaded).toBeUndefined(); + expect(h.panel.pending).toBeUndefined(); }); it("a timed-out feed is classed as a timeout", async () => { diff --git a/src/test/suite/whatsNewTrigger.test.ts b/src/test/suite/whatsNewTrigger.test.ts index 86f42105d..ad04f329e 100644 --- a/src/test/suite/whatsNewTrigger.test.ts +++ b/src/test/suite/whatsNewTrigger.test.ts @@ -11,7 +11,7 @@ const LAST_SEEN_VERSION_KEY = "whatsNew.lastSeenVersion"; * each manual reload consumes the trigger and the next attempt tests nothing. */ const activationHarness = ( - opts: { enabled?: boolean; feedDown?: boolean } = {}, + opts: { enabled?: boolean; feedDown?: boolean; deferred?: boolean } = {}, ) => { const store = new Map(); const shown: string[] = []; @@ -30,16 +30,32 @@ const activationHarness = ( const panel = Object.create(WhatsNewPanel.prototype) as Record< string, unknown - > & { checkAndShowOnActivation: () => Promise }; + > & { + checkAndShowOnActivation: () => Promise; + serveManifest: (id: string) => Promise; + }; panel.dbtProjectContainer = container; panel.show = (trigger: string) => shown.push(trigger); + const dbtTerminal = { debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const responses: Record[] = []; + panel.dbtTerminal = dbtTerminal; + panel.telemetry = { sendTelemetryEvent: jest.fn() }; + panel.sendResponseToWebview = (r: Record) => { + responses.push(r); + }; // The automatic open waits for the feed; a failed fetch is already logged // and reported inside fetchManifest, so here it only rejects. - const fetchManifest = jest.fn(async () => { + let resolveFetch: (m: typeof manifest) => void = () => undefined; + const fetchManifest = jest.fn(() => { if (opts.feedDown) { - throw new Error("Feed request failed with 403"); + return Promise.reject(new Error("Feed request failed with 403")); + } + if (opts.deferred) { + return new Promise((resolve) => { + resolveFetch = resolve; + }); } - return manifest; + return Promise.resolve(manifest); }); panel.fetchManifest = fetchManifest; @@ -51,7 +67,17 @@ const activationHarness = ( shown, manifest, fetchManifest, - preloaded: () => panel.preloaded, + pending: () => panel.pending as Promise | undefined, + container, + panel, + dbtTerminal, + responses, + resolveFetch: (m: typeof manifest) => resolveFetch(m), + /** The user runs "Show What's New": the panel exists, the page asks. */ + openManually: () => { + panel._panel = {}; + return panel.serveManifest("req"); + }, lastSeen: () => store.get(LAST_SEEN_VERSION_KEY), setLastSeen: (v: string) => store.set(LAST_SEEN_VERSION_KEY, v), /** Simulate the extension activating while reporting `version`. */ @@ -99,7 +125,7 @@ describe("WhatsNewPanel.checkAndShowOnActivation", () => { await h.activateAs("0.63.0"); expect(h.fetchManifest).toHaveBeenCalledWith("auto"); expect(h.shown).toEqual([]); - expect(h.preloaded()).toBeUndefined(); + expect(h.pending()).toBeUndefined(); expect(h.lastSeen()).toBe("0.63.0"); }); @@ -110,7 +136,50 @@ describe("WhatsNewPanel.checkAndShowOnActivation", () => { await h.activateAs("0.63.0"); expect(h.fetchManifest).toHaveBeenCalledTimes(1); expect(h.shown).toEqual(["auto"]); - expect(h.preloaded()).toBe(h.manifest); + await expect(h.pending()).resolves.toBe(h.manifest); + }); + + it("a manual open while the activation fetch is in flight shares that fetch", async () => { + const h = activationHarness({ deferred: true }); + h.setLastSeen("0.62.1"); + + const activation = h.activateAs("0.63.0"); // fetch still in flight + const manual = h.openManually(); // the user runs the command meanwhile + h.resolveFetch(h.manifest); + await Promise.all([activation, manual]); + + expect(h.fetchManifest).toHaveBeenCalledTimes(1); + expect(h.responses[0]).toMatchObject({ status: true, data: h.manifest }); + // The page the user opened is already in front; nothing to open on top. + expect(h.shown).toEqual([]); + expect(h.pending()).toBeUndefined(); + }); + + it("a page that fails to open is logged, and activation still settles", async () => { + const h = activationHarness(); + h.setLastSeen("0.62.1"); + h.panel.show = () => { + throw new Error("no webview host"); + }; + + await expect(h.activateAs("0.63.0")).resolves.toBeUndefined(); + expect(h.dbtTerminal.error).toHaveBeenCalledWith( + "whatsNew:checkAndShowOnActivation", + expect.any(String), + expect.any(Error), + ); + expect(h.pending()).toBeUndefined(); + }); + + it("a failing state read is logged, and activation still settles", async () => { + const h = activationHarness(); + h.container.getFromGlobalState = () => { + throw new Error("storage gone"); + }; + + await expect(h.activateAs("0.63.0")).resolves.toBeUndefined(); + expect(h.dbtTerminal.error).toHaveBeenCalledTimes(1); + expect(h.shown).toEqual([]); }); it("does not touch the feed when there is nothing to open", async () => { diff --git a/src/webview_provider/whatsNewPanel.ts b/src/webview_provider/whatsNewPanel.ts index dbb8aa7a0..4372755ce 100644 --- a/src/webview_provider/whatsNewPanel.ts +++ b/src/webview_provider/whatsNewPanel.ts @@ -187,8 +187,12 @@ export class WhatsNewPanel extends AltimateWebviewProvider { protected viewPath = "/whats-new"; protected panelDescription = "What's New in dbt Power User"; private currentTrigger: WhatsNewTrigger = "manual"; - /** Manifest fetched before an automatic open, served to the page once. */ - private preloaded: WhatsNewManifest | undefined; + /** + * The fetch started for an automatic open. The page's own request awaits + * it instead of fetching again, whether the page was opened by that + * automatic trigger or by the user in the meantime. + */ + private pending: Promise | undefined; public constructor( protected dbtProjectContainer: DBTProjectContainer, @@ -276,27 +280,37 @@ export class WhatsNewPanel extends AltimateWebviewProvider { * rejects. */ public async checkAndShowOnActivation(): Promise { - const current = this.dbtProjectContainer.extensionVersion; - const lastSeen = this.dbtProjectContainer.getFromGlobalState( - LAST_SEEN_VERSION_KEY, - ) as string | undefined; - - this.dbtProjectContainer.setToGlobalState(LAST_SEEN_VERSION_KEY, current); - - const enabled = workspace - .getConfiguration("dbt") - .get("showChangelogOnUpdate", true); - if (!enabled) { - return; - } + try { + const current = this.dbtProjectContainer.extensionVersion; + const lastSeen = this.dbtProjectContainer.getFromGlobalState( + LAST_SEEN_VERSION_KEY, + ) as string | undefined; + + this.dbtProjectContainer.setToGlobalState(LAST_SEEN_VERSION_KEY, current); + + const enabled = workspace + .getConfiguration("dbt") + .get("showChangelogOnUpdate", true); + if (!enabled) { + return; + } - // Fresh install: defer to the setup walkthrough rather than the changelog. - if (!lastSeen) { - return; - } + // Fresh install: defer to the setup walkthrough rather than the + // changelog. + if (!lastSeen) { + return; + } - if (WhatsNewPanel.isMinorOrMajorUpgrade(lastSeen, current)) { - await this.showWhenFeedAvailable(); + if (WhatsNewPanel.isMinorOrMajorUpgrade(lastSeen, current)) { + await this.showWhenFeedAvailable(); + } + } catch (error) { + // Nothing awaits this at activation; a failure here is ours to log. + this.dbtTerminal.error( + "whatsNew:checkAndShowOnActivation", + "What's New activation check failed", + error, + ); } } @@ -307,13 +321,31 @@ export class WhatsNewPanel extends AltimateWebviewProvider { * fetch a second time once it loads. */ private async showWhenFeedAvailable(): Promise { + const fetch = this.fetchManifest("auto"); + this.pending = fetch; + let fetched = false; try { - this.preloaded = await this.fetchManifest("auto"); + await fetch; + fetched = true; } catch { // fetchManifest has logged the failure and reported its reason. + } + // The user may have opened the page while the fetch was in flight; its + // request shared the fetch, and the page they asked for is already in + // front. A failed fetch is never replayed to a later open either. + const openedMeanwhile = this._panel !== undefined; + if (this.pending === fetch && (!fetched || openedMeanwhile)) { + this.pending = undefined; + } + if (!fetched || openedMeanwhile) { return; } - this.show("auto"); + try { + this.show("auto"); + } catch (error) { + this.pending = undefined; + throw error; + } } private static parseVersion( @@ -364,10 +396,9 @@ export class WhatsNewPanel extends AltimateWebviewProvider { syncRequestId: string | undefined, ): Promise { let manifest: WhatsNewManifest; - const preloaded = this.preloaded !== undefined; + const shared = this.pending; try { - manifest = - this.preloaded ?? (await this.fetchManifest(this.currentTrigger)); + manifest = await (shared ?? this.fetchManifest(this.currentTrigger)); } catch (error) { this.sendResponseToWebview({ command: "response", @@ -377,11 +408,13 @@ export class WhatsNewPanel extends AltimateWebviewProvider { }); return; } finally { - this.preloaded = undefined; + if (shared && this.pending === shared) { + this.pending = undefined; + } } this.dbtTerminal.debug( "whatsNew:serveManifest", - `Serving ${manifest.items.length} entries to the page (${preloaded ? "fetched before opening" : "fetched on request"})`, + `Serving ${manifest.items.length} entries to the page (${shared ? "fetched before opening" : "fetched on request"})`, ); this.telemetry.sendTelemetryEvent( TelemetryEvents["WhatsNew/PageOpened"], From 1423e2b70e7fdb4eb10b87eb9c5e3a3c940bbba3 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 14 Oct 2026 22:00:15 +0800 Subject: [PATCH 3/4] fix: do not reopen a page the user closed while the activation fetch was in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page the user opened during the activation fetch takes that fetch for its own request. If they close it before the fetch settles, the panel is gone by the time the automatic open decides, and it would bring the page back. The panel now remembers that its pending fetch was taken by a page, and the automatic open stays away in that case too. Test: manual open, request takes the fetch, panel closed, fetch settles → no automatic open, one fetch, nothing pending (fails on the previous commit). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- src/test/suite/whatsNewTrigger.test.ts | 16 ++++++++++++++++ src/webview_provider/whatsNewPanel.ts | 19 +++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/test/suite/whatsNewTrigger.test.ts b/src/test/suite/whatsNewTrigger.test.ts index ad04f329e..4793bd501 100644 --- a/src/test/suite/whatsNewTrigger.test.ts +++ b/src/test/suite/whatsNewTrigger.test.ts @@ -155,6 +155,22 @@ describe("WhatsNewPanel.checkAndShowOnActivation", () => { expect(h.pending()).toBeUndefined(); }); + it("a manual page closed before the shared fetch settles is not reopened", async () => { + const h = activationHarness({ deferred: true }); + h.setLastSeen("0.62.1"); + + const activation = h.activateAs("0.63.0"); // fetch still in flight + const manual = h.openManually(); // the page's request takes the fetch + h.panel._panel = undefined; // the user closes the page before it settles + h.resolveFetch(h.manifest); + await Promise.all([activation, manual]); + + // The user has had and dismissed the page; nothing comes back on its own. + expect(h.shown).toEqual([]); + expect(h.fetchManifest).toHaveBeenCalledTimes(1); + expect(h.pending()).toBeUndefined(); + }); + it("a page that fails to open is logged, and activation still settles", async () => { const h = activationHarness(); h.setLastSeen("0.62.1"); diff --git a/src/webview_provider/whatsNewPanel.ts b/src/webview_provider/whatsNewPanel.ts index 4372755ce..1a83b4e38 100644 --- a/src/webview_provider/whatsNewPanel.ts +++ b/src/webview_provider/whatsNewPanel.ts @@ -193,6 +193,12 @@ export class WhatsNewPanel extends AltimateWebviewProvider { * automatic trigger or by the user in the meantime. */ private pending: Promise | undefined; + /** + * Set once a page's request has taken `pending`. That page was opened by + * the user, and it stays theirs even if they close it before the fetch + * settles: the automatic open must not bring it back. + */ + private pendingTaken = false; public constructor( protected dbtProjectContainer: DBTProjectContainer, @@ -323,6 +329,7 @@ export class WhatsNewPanel extends AltimateWebviewProvider { private async showWhenFeedAvailable(): Promise { const fetch = this.fetchManifest("auto"); this.pending = fetch; + this.pendingTaken = false; let fetched = false; try { await fetch; @@ -330,10 +337,11 @@ export class WhatsNewPanel extends AltimateWebviewProvider { } catch { // fetchManifest has logged the failure and reported its reason. } - // The user may have opened the page while the fetch was in flight; its - // request shared the fetch, and the page they asked for is already in - // front. A failed fetch is never replayed to a later open either. - const openedMeanwhile = this._panel !== undefined; + // The user may have opened the page while the fetch was in flight: either + // its request already took the fetch (even if they have closed the page + // since), or the page is still loading and will take it. Nothing to open + // on top. A failed fetch is never replayed to a later open either. + const openedMeanwhile = this.pendingTaken || this._panel !== undefined; if (this.pending === fetch && (!fetched || openedMeanwhile)) { this.pending = undefined; } @@ -397,6 +405,9 @@ export class WhatsNewPanel extends AltimateWebviewProvider { ): Promise { let manifest: WhatsNewManifest; const shared = this.pending; + if (shared) { + this.pendingTaken = true; + } try { manifest = await (shared ?? this.fetchManifest(this.currentTrigger)); } catch (error) { From f6d261471c9dbcb485506ccb8a8630cc5263f1c7 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 14 Oct 2026 22:24:19 +0800 Subject: [PATCH 4/4] fix: keep the activation fetch for a page that is still loading Review follow-up: when the user opened the page during the activation fetch but its webview had not yet asked for the manifest, the automatic open cleared the pending fetch on the strength of the panel existing, and the page then fetched again. - The automatic open now stands down on a flag set by any manual open during the fetch, and clears the pending fetch only when it failed; a successful fetch stays for whichever page asks next. - A panel disposed before its page asked drops the pending fetch, so nothing stale is served to a later open. Tests: manual page still loading when the fetch settles takes it afterwards (one fetch); manual page closed before it ever asked is neither reopened nor served stale. Both fail on the previous commit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- src/test/suite/whatsNewTrigger.test.ts | 50 +++++++++++++++++++++- src/webview_provider/whatsNewPanel.ts | 58 ++++++++++++++++++-------- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/test/suite/whatsNewTrigger.test.ts b/src/test/suite/whatsNewTrigger.test.ts index 4793bd501..7fa621f36 100644 --- a/src/test/suite/whatsNewTrigger.test.ts +++ b/src/test/suite/whatsNewTrigger.test.ts @@ -33,6 +33,8 @@ const activationHarness = ( > & { checkAndShowOnActivation: () => Promise; serveManifest: (id: string) => Promise; + noteOpen: (trigger: string) => void; + onPanelDisposed: () => void; }; panel.dbtProjectContainer = container; panel.show = (trigger: string) => shown.push(trigger); @@ -75,9 +77,19 @@ const activationHarness = ( resolveFetch: (m: typeof manifest) => resolveFetch(m), /** The user runs "Show What's New": the panel exists, the page asks. */ openManually: () => { + panel.noteOpen("manual"); panel._panel = {}; return panel.serveManifest("req"); }, + /** Same, but the page is still loading and has not asked yet. */ + openManuallyPageLoading: () => { + panel.noteOpen("manual"); + panel._panel = {}; + }, + /** The page's request, once its webview is up. */ + pageRequests: () => panel.serveManifest("req"), + /** The user closes the page. */ + closePage: () => panel.onPanelDisposed(), lastSeen: () => store.get(LAST_SEEN_VERSION_KEY), setLastSeen: (v: string) => store.set(LAST_SEEN_VERSION_KEY, v), /** Simulate the extension activating while reporting `version`. */ @@ -161,7 +173,7 @@ describe("WhatsNewPanel.checkAndShowOnActivation", () => { const activation = h.activateAs("0.63.0"); // fetch still in flight const manual = h.openManually(); // the page's request takes the fetch - h.panel._panel = undefined; // the user closes the page before it settles + h.closePage(); // the user closes the page before it settles h.resolveFetch(h.manifest); await Promise.all([activation, manual]); @@ -171,6 +183,42 @@ describe("WhatsNewPanel.checkAndShowOnActivation", () => { expect(h.pending()).toBeUndefined(); }); + it("a manual page still loading when the fetch settles takes it afterwards", async () => { + const h = activationHarness({ deferred: true }); + h.setLastSeen("0.62.1"); + + const activation = h.activateAs("0.63.0"); // fetch still in flight + h.openManuallyPageLoading(); // panel exists, webview not up yet + h.resolveFetch(h.manifest); + await activation; + expect(h.shown).toEqual([]); // the user's page is in front + await h.pageRequests(); + + expect(h.fetchManifest).toHaveBeenCalledTimes(1); + expect(h.responses[0]).toMatchObject({ status: true, data: h.manifest }); + expect(h.pending()).toBeUndefined(); + }); + + it("a manual page closed before it ever asked is neither reopened nor served stale", async () => { + const h = activationHarness({ deferred: true }); + h.setLastSeen("0.62.1"); + + const activation = h.activateAs("0.63.0"); + h.openManuallyPageLoading(); + h.closePage(); // closed before the webview asked for anything + h.resolveFetch(h.manifest); + await activation; + + expect(h.shown).toEqual([]); + expect(h.pending()).toBeUndefined(); + // A later manual open fetches afresh. + const later = h.openManually(); + h.resolveFetch(h.manifest); + await later; + expect(h.fetchManifest).toHaveBeenCalledTimes(2); + expect(h.responses[0]).toMatchObject({ status: true, data: h.manifest }); + }); + it("a page that fails to open is logged, and activation still settles", async () => { const h = activationHarness(); h.setLastSeen("0.62.1"); diff --git a/src/webview_provider/whatsNewPanel.ts b/src/webview_provider/whatsNewPanel.ts index 1a83b4e38..ee359e3fb 100644 --- a/src/webview_provider/whatsNewPanel.ts +++ b/src/webview_provider/whatsNewPanel.ts @@ -199,6 +199,12 @@ export class WhatsNewPanel extends AltimateWebviewProvider { * settles: the automatic open must not bring it back. */ private pendingTaken = false; + /** + * Set when the user opens the page while the activation fetch is in + * flight. The automatic open then stands down whatever becomes of that + * page: its request takes the fetch, or they closed it before it loaded. + */ + private userOpenedMeanwhile = false; public constructor( protected dbtProjectContainer: DBTProjectContainer, @@ -230,11 +236,7 @@ export class WhatsNewPanel extends AltimateWebviewProvider { * Code's own Release Notes: a one-off webview in the center, not a sidebar. */ public show(trigger: WhatsNewTrigger): void { - this.currentTrigger = trigger; - this.dbtTerminal.debug( - "whatsNew:show", - `Opening the What's New page (trigger=${trigger})`, - ); + this.noteOpen(trigger); if (this._panel) { (this._panel as WebviewPanel).reveal?.(); @@ -257,16 +259,35 @@ export class WhatsNewPanel extends AltimateWebviewProvider { ); this._panel = webviewPanel; - webviewPanel.onDidDispose(() => { - this._panel = undefined; - this._webview = undefined; - this.isWebviewReady = false; - }); + webviewPanel.onDidDispose(() => this.onPanelDisposed()); this._webview = webviewPanel.webview; this.renderWebviewView(); } + /** The open decision's bookkeeping, separate from the webview plumbing. */ + private noteOpen(trigger: WhatsNewTrigger): void { + this.currentTrigger = trigger; + if (trigger === "manual" && this.pending !== undefined) { + this.userOpenedMeanwhile = true; + } + this.dbtTerminal.debug( + "whatsNew:show", + `Opening the What's New page (trigger=${trigger})`, + ); + } + + private onPanelDisposed(): void { + this._panel = undefined; + this._webview = undefined; + this.isWebviewReady = false; + // A page closed before it asked for the manifest leaves nothing to serve + // it to; a later open fetches afresh rather than reading a stale one. + if (!this.pendingTaken) { + this.pending = undefined; + } + } + protected renderWebviewView(): void { if (!this._webview) { return; @@ -330,6 +351,7 @@ export class WhatsNewPanel extends AltimateWebviewProvider { const fetch = this.fetchManifest("auto"); this.pending = fetch; this.pendingTaken = false; + this.userOpenedMeanwhile = false; let fetched = false; try { await fetch; @@ -337,15 +359,17 @@ export class WhatsNewPanel extends AltimateWebviewProvider { } catch { // fetchManifest has logged the failure and reported its reason. } - // The user may have opened the page while the fetch was in flight: either - // its request already took the fetch (even if they have closed the page - // since), or the page is still loading and will take it. Nothing to open - // on top. A failed fetch is never replayed to a later open either. - const openedMeanwhile = this.pendingTaken || this._panel !== undefined; - if (this.pending === fetch && (!fetched || openedMeanwhile)) { + // A failed fetch is never replayed to a later open. A successful one is + // kept for the page that will ask for it: the one opened below, or one + // the user opened meanwhile that is still loading (a page that already + // took it has cleared it itself). + if (this.pending === fetch && !fetched) { this.pending = undefined; } - if (!fetched || openedMeanwhile) { + // The user opened the page while the fetch was in flight (whatever became + // of it since), or a page is open for another reason: nothing to open on + // top. + if (!fetched || this.userOpenedMeanwhile || this._panel !== undefined) { return; } try {