Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"@popperjs/core": "^2.11.8",
"@skit/x.naive-ui": "^0.19.0",
"@tailwindcss/vite": "^4.3.3",
"@types/lodash": "^4.17.25",
"@unhead/vue": "^2.1.17",
"@vue-flow/core": "^1.48.2",
"@vue-flow/minimap": "^1.5.4",
Expand All @@ -41,7 +40,7 @@
"globals": "^17.12.0",
"idb": "^8.0.3",
"lightweight-charts": "^5.2.1",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"numbro": "^2.5.0",
"p-limit": "^7.3.3",
"papaparse": "^5.7.0",
Expand All @@ -59,6 +58,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "25.3.2",
"@types/papaparse": "^5.5.2",
"@typescript-eslint/parser": "^8.70.1",
Expand Down
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
ref,
watch,
} from "vue";
import { debounce } from "lodash";
import { debounce } from "lodash-es";

import { useI18n } from "vue-i18n";
const { t } = useI18n();
Expand Down
5 changes: 4 additions & 1 deletion src/features/help/components/HelpDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@
{{ displayDrawerTitle }}
</template>
<div v-if="markdownContent != ''" id="markdown">
<VueShowdown :markdown="markdownContent" />
<VueShowdown
:markdown="markdownContent"
flavor="github"
:options="{ tables: true, emoji: true }" />
</div>
<div v-else-if="loadFailed" class="text-center text-red-500">
{{ t("help.load_error", { file_name: fileName }) }}
Expand Down
2 changes: 1 addition & 1 deletion src/features/preferences/usePreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
watch,
type WritableComputedRef,
} from "vue";
import { debounce, isEqual, cloneDeep } from "lodash";
import { debounce, isEqual, cloneDeep } from "lodash-es";
import { i18n, type SupportedLocale } from "@/lib/i18n";
import type { Composer } from "vue-i18n";

Expand Down
2 changes: 1 addition & 1 deletion src/lib/analytics/useAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { debounce, type DebouncedFunc } from "lodash";
import { debounce, type DebouncedFunc } from "lodash-es";

import {
capture,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/analytics/usePostHog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ async function startAnalytics(): Promise<void> {
capture_pageview: false,
// would follow capture_pageview otherwise
capture_pageleave: true,
// names the element behind a slow LCP or a layout shift
capture_performance: { web_vitals_attribution: true },
// no cookie
persistence: "localStorage",
respect_dnt: true,
Expand Down
73 changes: 73 additions & 0 deletions src/lib/chunkReload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Recovers from chunks a deploy removed: an open tab still references the
* old build's hashed files, so the next lazy route fails to load. Reloads
* once to get the new build; if that already happened in the last 30 s the
* chunk is really broken, so the error is shown instead of looping.
*/

import type { Router } from "vue-router";
import { createDiscreteApi, darkTheme } from "naive-ui";
import { i18n } from "@/lib/i18n";

const KEY = "prunplanner_chunk_reload";
const GUARD_MS = 30_000;

export function isChunkLoadError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /dynamically imported module|Importing a module script failed|Unable to preload CSS/i.test(
message
);
}

/**
* Reloads (to url, if given) unless a reload already happened within the
* guard window. Returns whether it reloaded.
*/
export function reloadOnce(url?: string): boolean {
const last = Number(sessionStorage.getItem(KEY) ?? 0);
if (Date.now() - last < GUARD_MS) return false;

sessionStorage.setItem(KEY, String(Date.now()));
if (url) window.location.assign(url);
else window.location.reload();
return true;
}

function showError(error: unknown): void {
console.error(error);
const { message } = createDiscreteApi(["message"], {
configProviderProps: { theme: darkTheme },
});
message.error(i18n.global.t("common.ui.page_load_failed"), {
duration: 0,
closable: true,
});
}

export function registerChunkReload(router: Router): void {
// during a navigation the failed import reaches router.onError, which
// knows the target route; reloading here would land on the old page
let navigating = false;
router.beforeEach(() => {
navigating = true;
});
router.afterEach(() => {
navigating = false;
});

// any other lazy chunk (async components); Vite sends both failed
// preloads and failed imports here. posthog-js handles its own failure
// (a content blocker), a reload wouldn't help.
window.addEventListener("vite:preloadError", (event) => {
const error = (event as Event & { payload?: unknown }).payload;
if (navigating || !isChunkLoadError(error)) return;
if (String(error).includes("vendor_posthog")) return;
if (reloadOnce()) event.preventDefault();
});

router.onError((error, to) => {
navigating = false;
if (!isChunkLoadError(error)) return;
if (!reloadOnce(to.fullPath)) showError(error);
});
}
3 changes: 2 additions & 1 deletion src/locales/en_US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@
"page": "Page {page}",
"more": "More pages"
},
"language": "Language"
"language": "Language",
"page_load_failed": "This page couldn't load. Reload to get the latest version."
},
"values": {
"never": "never"
Expand Down
10 changes: 5 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ app.config.performance = true;
app.config.errorHandler = trackVueError;

app.use(router);

// reload once when a deploy removed a lazy chunk this tab still references
import { registerChunkReload } from "@/lib/chunkReload";
registerChunkReload(router);

app.use(pinia);

// locale
Expand Down Expand Up @@ -56,11 +61,6 @@ const head = createHead();

app.use(head);

// vue-showdown, markdown support
import { VueShowdownPlugin } from "vue-showdown";

app.use(VueShowdownPlugin, { flavor: "github", tables: true, emoji: true });

// directives
import clickOutsideDirective from "@/layout/directives/clickOutsideDirective";
import { useUserStore } from "./stores/userStore";
Expand Down
2 changes: 1 addition & 1 deletion src/stores/userStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type Ref,
watch,
} from "vue";
import merge from "lodash/merge";
import { merge } from "lodash-es";

// API
import {
Expand Down
1 change: 1 addition & 0 deletions src/tests/lib/analytics/usePostHog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe("usePostHog", () => {
persistence: "localStorage",
respect_dnt: true,
person_profiles: "identified_only",
capture_performance: { web_vitals_attribution: true },
})
);
expect(posthog.register).toHaveBeenCalledWith({
Expand Down
151 changes: 151 additions & 0 deletions src/tests/lib/chunkReload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Router } from "vue-router";

const messageError = vi.fn();
vi.mock("naive-ui", () => ({
darkTheme: {},
createDiscreteApi: () => ({ message: { error: messageError } }),
}));

import {
isChunkLoadError,
registerChunkReload,
reloadOnce,
} from "@/lib/chunkReload";

describe("chunkReload", () => {
const reload = vi.fn();
const assign = vi.fn();
// registerChunkReload adds a window listener per test
const listeners: EventListener[] = [];
const addEventListener = window.addEventListener.bind(window);

beforeEach(() => {
vi.useFakeTimers();
sessionStorage.clear();
reload.mockReset();
assign.mockReset();
messageError.mockReset();
vi.stubGlobal("location", { reload, assign });
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(window, "addEventListener").mockImplementation(
(type: string, listener: EventListener) => {
listeners.push(listener);
addEventListener(type, listener);
}
);
});

afterEach(() => {
listeners
.splice(0)
.forEach((l) => window.removeEventListener("vite:preloadError", l));
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("recognises failed dynamic imports", () => {
expect(
isChunkLoadError(
new TypeError(
"Failed to fetch dynamically imported module: /assets/a.js"
)
)
).toBe(true);
expect(
isChunkLoadError(new TypeError("Importing a module script failed."))
).toBe(true);
expect(isChunkLoadError(new Error("Request failed"))).toBe(false);
});

it("reloads once, then not again within 30 s", () => {
expect(reloadOnce()).toBe(true);
expect(reload).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(29_000);
expect(reloadOnce()).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(2_000);
expect(reloadOnce("/help")).toBe(true);
expect(assign).toHaveBeenCalledWith("/help");
});

function mockRouter() {
const hooks = {
before: vi.fn(),
after: vi.fn(),
error: vi.fn(),
};
registerChunkReload({
beforeEach: hooks.before,
afterEach: hooks.after,
onError: hooks.error,
} as unknown as Router);
return {
navigate: () => hooks.before.mock.calls[0][0](),
arrive: () => hooks.after.mock.calls[0][0](),
fail: (error: unknown, fullPath: string) =>
hooks.error.mock.calls[0][0](error, { fullPath }),
};
}

function preloadError(message: string): Event {
const event = new Event("vite:preloadError", { cancelable: true });
Object.assign(event, { payload: new TypeError(message) });
window.dispatchEvent(event);
return event;
}

const missing = "Failed to fetch dynamically imported module: /assets/b.js";

it("reloads once on a failed chunk outside a navigation", () => {
mockRouter();

expect(preloadError(missing).defaultPrevented).toBe(true);
expect(preloadError(missing).defaultPrevented).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);
});

it("leaves posthog and other errors alone", () => {
mockRouter();

preloadError(
"Failed to fetch dynamically imported module: /assets/chunks/vendor_posthog.x.js"
);
preloadError("something else");
expect(reload).not.toHaveBeenCalled();
});

it("reloads to the target route during a navigation, then shows the error", () => {
const router = mockRouter();
const error = new TypeError(missing);

router.navigate();
// Vite reports the failed import first: the router handles it
expect(preloadError(missing).defaultPrevented).toBe(false);
expect(reload).not.toHaveBeenCalled();

router.fail(error, "/help");
expect(assign).toHaveBeenCalledWith("/help");
expect(messageError).not.toHaveBeenCalled();

router.navigate();
router.fail(error, "/help");
expect(assign).toHaveBeenCalledTimes(1);
expect(messageError).toHaveBeenCalledTimes(1);

// other router errors are left alone
router.fail(new Error("guard failed"), "/x");
expect(messageError).toHaveBeenCalledTimes(1);
});

it("handles chunks again after the navigation ended", () => {
const router = mockRouter();
router.navigate();
router.arrive();

expect(preloadError(missing).defaultPrevented).toBe(true);
});
});
5 changes: 4 additions & 1 deletion src/views/HelpView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@
v-if="markdownContent != ''"
id="markdown"
class="h-screen overflow-auto">
<VueShowdown :markdown="markdownContent" />
<VueShowdown
:markdown="markdownContent"
flavor="github"
:options="{ tables: true, emoji: true }" />
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/views/tools/HQUpgradeCalculatorView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
ref,
watch,
} from "vue";
import { debounce } from "lodash";
import { debounce } from "lodash-es";
import { useI18n } from "vue-i18n";
const { t } = useI18n();

Expand Down
Loading
Loading