perf(series): the episode list no longer waits on the server
Opening Frasier on a Fairphone took ~5 s to render the episode list although every episode was cached. Three causes: - resolve_series_view waited for Next Up and resume before returning the episodes, and Next Up was server-first. The episode list now returns as soon as the episodes are in (with_hints); hints that have answered are used, late ones dropped, and the picker falls back to local watch state. Next Up is cache-first like every other query. - The page loaded itself six times per open: onMount plus a mount-time $effect, the reachability effect's first run posing as a reconnect, and a double mount. All triggers now share one coalesced load per item (createCoalescedLoader); refresh triggers get one re-run after it. - The root layout rendered the route in two branches that each rendered children; the page store deciding between them updates a flush late, so navigating Search -> library page mounted the page twice. One element now renders the route and only its classes change. On the device: one load per open, seasons from cache in 14 ms, episodes and the Resume button up in under a second (was ~5 s).
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createCoalescedLoader } from "./coalescedLoader";
|
||||
|
||||
/**
|
||||
* TRACES: UR-062 | DR-295
|
||||
*
|
||||
* The series page loaded itself six times on every open: `onMount` and a
|
||||
* `$effect` both ran on mount, the "server became reachable" effect fired on
|
||||
* its first run, and navigation updates re-ran the effect. Each load repeated
|
||||
* the item, the season list and the whole series view — about six times a
|
||||
* dozen requests in flight at once, which alone slowed every server call on a
|
||||
* phone to 2-3 s.
|
||||
*/
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("createCoalescedLoader", () => {
|
||||
it("shares one run between calls for the same key while it is in flight", async () => {
|
||||
const gate = deferred();
|
||||
const run = vi.fn(() => gate.promise);
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
const calls = [1, 2, 3, 4, 5, 6].map(() => loader.load("frasier"));
|
||||
gate.resolve();
|
||||
await Promise.all(calls);
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-runs once after the in-flight load when a caller needs fresh data", async () => {
|
||||
const gates = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const run = vi.fn(() => gates[n++].promise);
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
const first = loader.load("frasier");
|
||||
// e.g. "mark watched" finished while the page was still loading: the
|
||||
// in-flight load may predate the change, so it must not be the answer.
|
||||
const fresh = loader.load("frasier", { fresh: true });
|
||||
const fresh2 = loader.load("frasier", { fresh: true });
|
||||
gates[0].resolve();
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
|
||||
gates[1].resolve();
|
||||
// Every caller is answered by the load that includes the re-run.
|
||||
await Promise.all([first, fresh, fresh2]);
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share a run between different keys", async () => {
|
||||
const run = vi.fn(() => Promise.resolve());
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
await Promise.all([loader.load("frasier"), loader.load("cheers")]);
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenNthCalledWith(1, "frasier");
|
||||
expect(run).toHaveBeenNthCalledWith(2, "cheers");
|
||||
});
|
||||
|
||||
it("runs again once the previous load has finished", async () => {
|
||||
const run = vi.fn(() => Promise.resolve());
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
await loader.load("frasier");
|
||||
await loader.load("frasier");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("releases the key when a load fails", async () => {
|
||||
const run = vi
|
||||
.fn<(key: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const loader = createCoalescedLoader(run);
|
||||
|
||||
await expect(loader.load("frasier")).rejects.toThrow("offline");
|
||||
await loader.load("frasier");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Load one keyed thing at a time, however many triggers ask for it.
|
||||
*
|
||||
* Calls for the key already loading share that load instead of starting their
|
||||
* own. A caller that knows the data changed (`fresh` — after "mark watched",
|
||||
* on reconnect, when a filter flips) must not be answered by a load that may
|
||||
* predate the change, so it gets exactly one re-run once the current load
|
||||
* ends, however many such callers there were.
|
||||
*
|
||||
* Exists because the series page loaded itself six times on every open —
|
||||
* `onMount`, a mount-time `$effect`, the reachability effect's first run and
|
||||
* navigation updates each started a full load — putting about six times a
|
||||
* dozen requests in flight at once.
|
||||
*
|
||||
* TRACES: UR-062 | DR-295
|
||||
*/
|
||||
export interface CoalescedLoader {
|
||||
/** Load `key`. `fresh`: the caller knows the data changed. */
|
||||
load(key: string, options?: { fresh?: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
interface InFlight {
|
||||
key: string;
|
||||
/** Settles when this load and any re-run it owes have finished. */
|
||||
done: Promise<void>;
|
||||
rerun: boolean;
|
||||
}
|
||||
|
||||
export function createCoalescedLoader(run: (key: string) => Promise<void>): CoalescedLoader {
|
||||
let inFlight: InFlight | null = null;
|
||||
|
||||
return {
|
||||
load(key, options = {}) {
|
||||
if (inFlight && inFlight.key === key) {
|
||||
if (options.fresh) inFlight.rerun = true;
|
||||
return inFlight.done;
|
||||
}
|
||||
|
||||
const entry: InFlight = { key, rerun: false, done: Promise.resolve() };
|
||||
entry.done = (async () => {
|
||||
try {
|
||||
await run(key);
|
||||
while (entry.rerun) {
|
||||
entry.rerun = false;
|
||||
await run(key);
|
||||
}
|
||||
} finally {
|
||||
if (inFlight === entry) inFlight = null;
|
||||
}
|
||||
})();
|
||||
inFlight = entry;
|
||||
return entry.done;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user