Cold /chart, /people, /synastry read in one round and stop re-reading the shell's data (issue #127, BUG-1478)

- The HTML early-read script also runs on /chart, /people and /synastry:
  account, people list and the current person's sessions, plus on /chart the
  remembered person's chart-view (bound to that person, page deadline kept).
  The model list stays home-only.
- Account and people-list early reads hand one clone to the shell and one to
  the page, once each, in either order; later reloads go to the network.
- Synastry sends product-access, account, people and history together; access
  is still decided before anything about people paints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 12:11:03 +08:00
co-authored by Claude Opus 5.5
parent 16fcb8c289
commit 06d0c7dc37
12 changed files with 314 additions and 23 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 星盘页、档案页、合盘页打开更快
- 直接打开或刷新星盘页时,账户、人物和这张盘在网页一到就同时开始读取,不再等页面代码跑完、账户回来以后才开始算盘。
- 档案页、合盘页不再把账户和人物列表各读两遍;合盘页几条互不相关的读取改为同时发出。
- 看到的内容不变。Skill 版本不变(BUG-1478,issue #127)。
## 2026-10-28 — 用链接打开对话更快
- 从分享链接、侧栏历史或刷新打开某条对话时,对话内容和账户同时开始读取,少等一轮(手机上约 0.15~0.4 秒)。打开别人的或已删除的对话,提示和以前一样。
+16
View File
@@ -20171,3 +20171,19 @@
- 关联:BUG-1127(早读脚本)、BUG-1040(换账户丢弃早读)、BUG-1474(`?c=` 揭幕前等排版组件)。
- 复发自:无
- 修复版本:分支 `codex/home-early-session-detail-20261028`。
## BUG-1478 | 星盘、档案、合盘页冷开:代码 → 账户 → 页面主读串行,档案和合盘页还重复读账户和人物列表
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 18 条 → #127)
- 影响面:冷开 `/chart`、`/people`、`/synastry`;`frontend/src/lib/boot-early-reads.ts`、`lib/chart-view-client.ts`、`lib/secondary-page-data.ts`、`lib/home-cloud-sync.ts`(`fetchAccount`)、`components/people/people-page.tsx`、`components/synastry/synastry-picker.tsx`。
- 现象:① `/chart` 要等页面代码下载执行完才去读账户,账户回来了才去算星盘(`/api/chart-view` 是重计算);② `/people`、`/synastry` 外壳已经读了账户和人物列表,页面自己又各读一遍;③ 合盘页先等开关(`/api/product-access`)回来,再读账户和人物,最后再读历史报告,三轮串行。
- 触发条件:刷新或直接打开这三个页面。
- 根因:BUG-1127 的早读脚本只在 `/` 发;页面和外壳各自取数,互不共享;合盘页把互不依赖的请求写成了先后 `await`。
- 修复:早读脚本扩到 `/chart`、`/people`、`/synastry`:同一轮发账户、人物列表、当前人物的会话三条(外壳要用);`/chart` 再按本机记住的人物发一条与页面首读完全相同的 `/api/chart-view?subject=…`,按人物绑定,人物不对就丢弃(与会话猜测同一条红线),首读仍受页面原来的超时约束,账户对不上照旧由盘面身份校验重读;模型目录只在首页读。账户和人物列表这两条早读改为「外壳一份、页面一份」,各拿一个 `clone()`,谁先谁后都行,各拿一次;之后页面里改完资料的重新读取照常发新请求,不会拿到旧数据。合盘页的开关、账户、人物、历史报告四条同时发出,开关没回来之前照旧不画选人区。
- 验证:新测试 `frontend/tests/secondary-page-early-reads-20261028.test.tsx`:冷开 `/chart` 一次同步执行发出账户、人物、会话、星盘四条,星盘这条的路径、缓存选项、请求头与页面自己发的一致;`/people`、`/synastry` 只发三条,不读模型目录;外壳和页面各读一次账户、先后顺序两种都对,之后的重新读取走网络;星盘早读只给对应人物;早读挂住时页面超时照样生效;合盘页四条请求在任何一条返回前都已发出(改前只发出开关一条)。`home-first-load-20260930`「早读只在 / 发」改为「只在 / 和这三页发,其余页面不发」,`synastry-full-report` 一处断言改认「开关判定仍在用到账户之前」。全量前端测试与 staging 逐条比对无新增失败;tsc 0、lint 0。`next build` 后 `/` 仍 ○ Static;首屏 gzip-9 合计 `/` +397 B、`/chart` +379 B(+0.07%)。
- 冷开请求数(代码路径):`/people` 改前外壳 3 条 + 页面 2 条,改后 HTML 3 条;`/synastry` 改前 3 + 开关 1 + 账户人物 2 + 历史 1,共三轮,改后 HTML 3 条 + 页面 2 条,一轮;`/chart` 改前代码 → 账户 → 星盘三段串行,改后四条都在 HTML 阶段发出。
- 防复发:上述测试。页面和外壳共用一份启动读取,互不依赖的请求同时发。
- 关联:BUG-1127(首页早读)、BUG-1477(`?c=` 早读)、BUG-1403(合盘开关未返回时不闪选人页)、BUG-1469(#120 次级页首屏)。
- 复发自:无
- 修复版本:分支 `codex/secondary-page-early-reads-20261028`。
+6
View File
@@ -40,3 +40,9 @@
19. 复制一条自己对话的链接(地址栏里带 `?c=`),新开一个无痕窗口登录后直接打开:对话一次出现、内容齐全。
20. 打开一条已删除的对话链接:仍提示「该对话不存在或已被删除」,停在空白首页。
## #127 星盘、档案、合盘页冷开更快(BUG-1478)
21. 在星盘页按刷新:盘照常出来,人物是上次看的那一位;切到另一个人再刷新,出来的是这个人的盘,不会先闪一下上一个人的。
22. 在档案页按刷新:列表和以前一样;改一个人的资料、添加一个人后,列表马上是新的。
23. 在合盘页按刷新:选人区、余额、历史报告照常;合盘服务关着时仍只显示「合盘服务暂未开放」,不闪选人区。
@@ -9,6 +9,7 @@ import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { personRelationLabel } from "@/lib/person-relation";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { takeBootReadForPage, withBootFallback } from "@/lib/boot-early-reads";
import { fetchAccount } from "@/lib/home-cloud-sync";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import { saveOtherProfile } from "@/lib/other-profile-save";
@@ -130,9 +131,11 @@ export function PeoplePage() {
async function load() {
setStatus("loading");
try {
// A cold open takes the HTML's account and people list; after that the
// copies are gone and a reload after an edit reads fresh (issue #127).
const [account, response] = await Promise.all([
fetchAccount(),
fetch("/api/chart-profiles", { cache: "no-store" }),
fetchAccount(undefined, takeBootReadForPage("account")),
withBootFallback(takeBootReadForPage("chartProfiles"), () => fetch("/api/chart-profiles", { cache: "no-store" })),
]);
if (!response.ok) throw new Error("archive_unavailable");
const payload = await response.json() as {
@@ -8,6 +8,7 @@ import { emptySynastryDraft, SynastryAddDialog } from "@/components/synastry/syn
import { SecondaryHeader } from "@/components/secondary-header";
import { accountDialogHref } from "@/lib/account-dialog-intent";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { takeBootReadForPage, withBootFallback } from "@/lib/boot-early-reads";
import { fetchAccount } from "@/lib/home-cloud-sync";
import type { Profile } from "@/lib/home-types";
import { missingOtherProfileStep, readProfile } from "@/lib/home-profile";
@@ -45,6 +46,17 @@ export function synastryChargeCopy(creditCost: number, balance: number): { short
return { short: false, text: `会用掉 ${creditCost} 点,余额 ${balance} 点。` };
}
type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
function settled<T>(promise: Promise<T>): Promise<Settled<T>> {
return promise.then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }));
}
function unwrap<T>(result: Settled<T>): T {
if (!result.ok) throw result.error;
return result.value;
}
export function SynastryPicker() {
const [people, setPeople] = useState<Person[]>([]);
const [left, setLeft] = useState("self");
@@ -63,13 +75,18 @@ export function SynastryPicker() {
const [confirming, setConfirming] = useState(false);
async function load(prefer?: { id: string; slot: Slot }) {
let priceResponse: Response;
try {
priceResponse = await fetch("/api/product-access", { cache: "no-store" });
} catch {
// The four reads are independent, so they go out together (issue #127);
// a cold open takes the HTML's account and people list instead of asking again.
const priceRead = settled(fetch("/api/product-access", { cache: "no-store" }));
const accountRead = settled(fetchAccount(undefined, takeBootReadForPage("account")));
const profilesRead = settled(withBootFallback(takeBootReadForPage("chartProfiles"), () => fetch("/api/chart-profiles", { cache: "no-store" })));
const historyRead = settled(fetch("/api/reports?reportType=synastry_full&limit=10", { cache: "no-store" }));
const priceResult = await priceRead;
if (!priceResult.ok) {
setAccess("unreachable");
return;
}
const priceResponse = priceResult.value;
const price = await priceResponse.json().catch(() => null) as { compatibility?: boolean; synastryCreditCost?: number | null } | null;
if (priceResponse.ok && price?.compatibility === false) {
setAccess("closed");
@@ -87,10 +104,8 @@ export function SynastryPicker() {
setCreditCost(null);
setPricingNote("计费配置不可用");
}
const [account, response] = await Promise.all([
fetchAccount(),
fetch("/api/chart-profiles", { cache: "no-store" }),
]);
const account = unwrap(await accountRead);
const response = unwrap(await profilesRead);
setBalance(typeof account.credits === "number" ? account.credits : null);
if (!response.ok) throw new Error("archive_unavailable");
const payload = await response.json() as { profiles?: Array<{ id?: string; profile?: unknown }> };
@@ -112,7 +127,7 @@ export function SynastryPicker() {
const wantedPerson = rows.find((person) => person.id === wanted);
if (wantedPerson && !wantedPerson.incomplete) setRight(wanted);
}
const historyResponse = await fetch("/api/reports?reportType=synastry_full&limit=10", { cache: "no-store" });
const historyResponse = unwrap(await historyRead);
const history = await historyResponse.json().catch(() => null) as { reports?: Earlier[] } | null;
setEarlier(Array.isArray(history?.reports) ? history.reports.filter((report) => report.id) : []);
}
+64 -5
View File
@@ -32,6 +32,15 @@ export const CURRENT_SUBJECT_KEY_PREFIX = "jyotisha_current_subject:";
/** An early read older than this is not trusted; the boot reads fresh instead. */
export const BOOT_READ_MAX_AGE_MS = 30_000;
/** Pages the HTML reads for (issue #126 added `?c=`, #127 the three below). */
export const BOOT_READ_PAGES = ["/", "/chart", "/people", "/synastry"] as const;
/** Same request `fetchChartView` makes for the chart page's first read. */
export function bootChartViewPath(subjectId: string): string {
return `/api/chart-view?subject=${encodeURIComponent(subjectId)}`;
}
export const BOOT_CHART_VIEW_HEADERS = { Accept: "application/json" } as const;
/** Same path `fetchSessionDetail` and `lookupSessionById` read. */
export function bootSessionDetailPath(sessionId: string): string {
return `/api/sessions/${encodeURIComponent(sessionId)}`;
@@ -50,9 +59,14 @@ type BootReads = {
sessionsSubject?: string;
sessionDetail?: Promise<Response>;
sessionDetailId?: string;
chartView?: Promise<Response>;
chartViewSubject?: string;
/** Which takers already had their copy of a shared read (issue #127). */
taken?: Partial<Record<string, true>>;
};
type BootReadKey = "account" | "chartProfiles" | "models";
type SharedBootReadKey = "account" | "chartProfiles";
function js(value: string): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
@@ -63,25 +77,33 @@ export function bootEarlyReadsScript(): string {
const init = js(BOOT_READ_INIT.cache as string);
return `(function () {
try {
if (window.location.pathname !== "/" || typeof window.fetch !== "function" || typeof window.Promise !== "function") return;
var page = window.location.pathname;
if (${JSON.stringify(BOOT_READ_PAGES)}.indexOf(page) < 0 || typeof window.fetch !== "function" || typeof window.Promise !== "function") return;
var subject = "self";
try {
var account = window.localStorage.getItem(${js(LAST_SUBJECT_ACCOUNT_KEY)}) || "";
if (account) subject = window.localStorage.getItem(${js(CURRENT_SUBJECT_KEY_PREFIX)} + account) || "self";
} catch (storageError) { subject = "self"; }
var read = function (path) {
var request = window.fetch(path, { cache: ${init} });
var read = function (path, headers) {
var request = window.fetch(path, headers ? { cache: ${init}, headers: headers } : { cache: ${init} });
request["catch"](function () {});
return request;
};
window[${js(BOOT_READS_GLOBAL)}] = {
var reads = window[${js(BOOT_READS_GLOBAL)}] = {
startedAt: Date.now(),
account: read(${js(BOOT_ACCOUNT_PATH)}),
chartProfiles: read(${js(BOOT_CHART_PROFILES_PATH)}),
models: read(${js(BOOT_MODELS_PATH)}),
sessions: read(${js(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=`)} + encodeURIComponent(subject)),
sessionsSubject: subject
};
// Only the home reads the model list; the chart page's first read is the
// remembered person's chart, bound to that person like the sessions guess.
if (page === "/") reads.models = read(${js(BOOT_MODELS_PATH)});
if (page === "/chart") {
reads.chartView = read(${js("/api/chart-view?subject=")} + encodeURIComponent(subject), ${JSON.stringify(BOOT_CHART_VIEW_HEADERS)});
reads.chartViewSubject = subject;
}
if (page !== "/") return;
// A \`?c=\` link: its conversation goes out in this same round (issue #126).
var detail = /[?&]c=([^&#]*)/.exec(window.location.search || "");
var detailId = "";
@@ -122,8 +144,26 @@ export function clearBootReads(): void {
delete (window as unknown as Record<string, unknown>)[BOOT_READS_GLOBAL];
}
/**
* A copy of a shared early read for one taker, once. The shell and the page
* each get their own `clone()`, in either order, so a cold `/people` or
* `/synastry` does not read the account and people list twice (issue #127).
* The original response is never read itself, so every clone has its body.
*/
function takeCopy(key: SharedBootReadKey, taker: "shell" | "page"): Promise<Response> | null {
const reads = bootReads();
const read = reads?.[key];
if (!reads || !read) return null;
const taken = (reads.taken ??= {});
if (taken[`${key}:${taker}`]) return null;
taken[`${key}:${taker}`] = true;
if (taken[`${key}:shell`] && taken[`${key}:page`]) delete reads[key];
return read.then((response) => response.clone());
}
/** The early read for `key`, once; later calls get null. */
export function takeBootRead(key: BootReadKey): Promise<Response> | null {
if (key !== "models") return takeCopy(key, "shell");
const reads = bootReads();
const read = reads?.[key];
if (!reads || !read) return null;
@@ -131,6 +171,25 @@ export function takeBootRead(key: BootReadKey): Promise<Response> | null {
return read;
}
/** The page's own copy of the account or people-list early read, once (issue #127). */
export function takeBootReadForPage(key: SharedBootReadKey): Promise<Response> | null {
return takeCopy(key, "page");
}
/**
* The chart page's early chart read, once, and only for `subjectId`; a read
* for another person is dropped (same red line as the sessions guess).
*/
export function takeBootChartViewRead(subjectId: string): Promise<Response> | null {
const reads = bootReads();
if (!reads?.chartView) return null;
const read = reads.chartView;
const asked = reads.chartViewSubject;
delete reads.chartView;
delete reads.chartViewSubject;
return asked === subjectId ? read : null;
}
/**
* The early sessions read, once, and only if it asked for `subjectId`. A read
* for another person is dropped, never returned (task red line 3).
+16 -1
View File
@@ -1,3 +1,4 @@
import { withBootFallback } from "./boot-early-reads.ts";
import { chartViewResponseSchema, type ChartViewResponse } from "./chart-view-contract.ts";
import type { ChartViewLayer } from "./chart-view-engine.ts";
import { chartViewFailureResponse } from "./chart-view-failure.ts";
@@ -8,6 +9,8 @@ export async function fetchChartView(input: {
subjectId?: string;
/** Transit layer day (YYYY-MM-DD); the server uses today when absent. */
transitDate?: string;
/** The HTML-sent first read of a cold `/chart` (issue #127), taken by the caller. */
early?: Promise<Response> | null;
}): Promise<{ httpStatus: number; body: ChartViewResponse | null }> {
const params = new URLSearchParams();
if (input.layers?.length) params.set("layers", input.layers.join(","));
@@ -15,12 +18,13 @@ export async function fetchChartView(input: {
if (input.transitDate) params.set("transitDate", input.transitDate);
const serialized = params.toString();
const query = serialized ? `?${serialized}` : "";
const response = await fetch(`/api/chart-view${query}`, {
const fresh = () => fetch(`/api/chart-view${query}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
cache: "no-store",
signal: input.signal,
});
const response = await withBootFallback(input.early ? untilAborted(input.early, input.signal) : null, fresh);
// Authentication is known from the headers; a missing body must not keep
// a signed-out reader waiting for the JSON deadline.
if (response.status === 401) return { httpStatus: 401, body: null };
@@ -41,3 +45,14 @@ export async function fetchChartView(input: {
}
return { httpStatus: response.status, body: parsed.data };
}
/** The early read was sent without the page's deadline; the deadline still applies. */
function untilAborted(early: Promise<Response>, signal: AbortSignal | undefined): Promise<Response> {
if (!signal) return early;
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
early.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
+3 -2
View File
@@ -480,8 +480,9 @@ export function waitForUndoWindow(signal: AbortSignal) {
});
}
export async function fetchAccount(signal?: AbortSignal): Promise<Account> {
const response = await fetch("/api/account", { signal, cache: "no-store" });
/** `early`: the HTML-sent cold-boot read, taken by the caller (boot-early-reads.ts). */
export async function fetchAccount(signal?: AbortSignal, early: Promise<Response> | null = null): Promise<Account> {
const response = await withBootFallback(early, () => fetch("/api/account", { signal, cache: "no-store" }));
if (response.status === 401) redirectToLogin();
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息"));
+3 -1
View File
@@ -1,3 +1,4 @@
import { takeBootChartViewRead } from "@/lib/boot-early-reads";
import { fetchChartView } from "@/lib/chart-view-client";
import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject";
import type { ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract";
@@ -205,7 +206,8 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<
};
chartSlot = null;
}
const first = snapshotFromChartResult(await fetchChartView({ signal, subjectId }));
// A cold `/chart` sent this read from the HTML for the remembered person (issue #127).
const first = snapshotFromChartResult(await fetchChartView({ signal, subjectId, early: takeBootChartViewRead(subjectId) }));
if (readCurrentSubjectId() !== subjectId) return refreshChartPage();
if (started !== chartEpoch) return refreshChartPage();
let snapshot = first;
@@ -227,9 +227,12 @@ function runBootScript(pathname: string, storage: Record<string, string> = {}) {
return { sent, window };
}
test("the early-read script is classic ES5 and only acts on / (T8)", () => {
test("the early-read script is classic ES5 and only acts on / and the pages it reads for (T8)", () => {
assert.doesNotThrow(() => acorn.parse(bootEarlyReadsScript(), { ecmaVersion: 5, sourceType: "script" }));
for (const path of ["/chart", "/reports", "/login", "/people", "/admin"]) {
// 原值:除 / 以外的页面(含 /chart、/people)一律不发早读
// 新值:/chart、/people、/synastry 也发账户、人物、会话三条(/chart 再加一条记住的人物的盘);其他页面仍不发
// 原因:issue #127 次级页冷开去掉「代码 → 账户 → 页面主读」的串行
for (const path of ["/reports", "/login", "/admin", "/ephemeris"]) {
assert.deepEqual(runBootScript(path).sent, [], `${path} sends nothing`);
}
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
@@ -0,0 +1,162 @@
/**
* Issue #127: cold `/chart`, `/people`, `/synastry` read their account, people
* list and (on /chart) the remembered person's chart from the HTML, and the
* page no longer asks again for what the shell already has.
*/
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import React, { createElement } from "react";
import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import {
BOOT_ACCOUNT_PATH,
BOOT_CHART_PROFILES_PATH,
BOOT_CHART_VIEW_HEADERS,
BOOT_MODELS_PATH,
BOOT_READ_INIT,
BOOT_READS_GLOBAL,
bootChartViewPath,
bootEarlyReadsScript,
bootSessionsPath,
clearBootReads,
takeBootChartViewRead,
takeBootRead,
takeBootReadForPage,
} from "../src/lib/boot-early-reads.ts";
import { fetchChartView } from "../src/lib/chart-view-client.ts";
import { fetchAccount } from "../src/lib/home-cloud-sync.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
const PERSON = "33333333-3333-4333-8333-333333333127";
const ACCOUNT = "11111111-1111-4111-8111-111111111127";
function runBootScript(pathname: string, storage: Record<string, string> = {}) {
const sent: { path: string; init: RequestInit }[] = [];
const window: Record<string, unknown> = {
location: { pathname, search: "" },
fetch: (path: string, init: RequestInit) => { sent.push({ path, init }); return Promise.resolve(new Response("{}")); },
Promise,
localStorage: { getItem: (key: string) => storage[key] ?? null },
};
vm.runInNewContext(bootEarlyReadsScript(), { window, Date, encodeURIComponent });
return { sent, reads: window[BOOT_READS_GLOBAL] as { chartViewSubject?: string } };
}
test("cold /chart sends account, people, sessions and the remembered person's chart in one round", () => {
const remembered = { jyotisha_current_subject_account: ACCOUNT, [`jyotisha_current_subject:${ACCOUNT}`]: PERSON };
const { sent, reads } = runBootScript("/chart", remembered);
assert.deepEqual(sent.map((request) => request.path).sort(), [
BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, bootSessionsPath(PERSON), bootChartViewPath(PERSON),
].sort());
assert.equal(reads.chartViewSubject, PERSON);
const chart = sent.find((request) => request.path === bootChartViewPath(PERSON))!;
// The init object comes from the script's own realm; compare its values.
assert.deepEqual(JSON.parse(JSON.stringify(chart.init)), { ...BOOT_READ_INIT, headers: BOOT_CHART_VIEW_HEADERS });
assert.ok(!sent.some((request) => request.path === BOOT_MODELS_PATH), "only the home reads the model list");
});
test("cold /people and /synastry send the shell's three reads and nothing else", () => {
for (const page of ["/people", "/synastry"]) {
const { sent } = runBootScript(page);
assert.deepEqual(sent.map((request) => request.path).sort(), [BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, bootSessionsPath("self")].sort(), page);
}
});
test("the chart page's early read is the request the page would make itself", async () => {
const seen: { url: string; init?: RequestInit }[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => {
seen.push({ url: String(url), init });
return new Response("{}", { status: 503 });
}) as typeof fetch;
try {
await fetchChartView({ subjectId: PERSON });
} finally {
globalThis.fetch = original;
}
assert.equal(seen[0].url, bootChartViewPath(PERSON));
assert.deepEqual(seen[0].init?.headers, BOOT_CHART_VIEW_HEADERS);
assert.equal(seen[0].init?.cache, BOOT_READ_INIT.cache);
});
function withEarlyReads(reads: Record<string, unknown>, run: (fetched: string[]) => Promise<void>) {
const host = globalThis as unknown as Record<string, unknown>;
const originals = { window: host.window, fetch: globalThis.fetch };
const fetched: string[] = [];
host.window = globalThis;
globalThis.fetch = (async (input: RequestInfo | URL) => { fetched.push(String(input)); return new Response("{}", { status: 500 }); }) as typeof fetch;
host[BOOT_READS_GLOBAL] = { startedAt: Date.now(), ...reads };
return run(fetched).finally(() => {
clearBootReads();
globalThis.fetch = originals.fetch;
if (originals.window === undefined) Reflect.deleteProperty(host, "window"); else host.window = originals.window;
});
}
const json = (payload: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(payload), { status }));
const account = { user: { id: ACCOUNT, email: null }, profile: { name: "合成" }, credits: 4 };
test("the shell and the page each read the account once from one request, in either order", async () => {
for (const pageFirst of [true, false]) {
await withEarlyReads({ account: json(account), chartProfiles: json({ profiles: [] }) }, async (fetched) => {
const page = () => fetchAccount(undefined, takeBootReadForPage("account"));
const shell = async () => (await takeBootRead("account"))!.json();
const [a, b] = pageFirst ? [await page(), await shell()] : [await shell(), await page()];
assert.equal((a as typeof account).user.id, ACCOUNT);
assert.equal((b as typeof account).user.id, ACCOUNT);
assert.equal(takeBootRead("account"), null, "the shell's copy is taken once");
assert.equal(takeBootReadForPage("account"), null, "the page's copy is taken once");
// A later reload (after an edit) reads fresh, as before.
await assert.rejects(fetchAccount(undefined, takeBootReadForPage("account")));
assert.deepEqual(fetched, ["/api/account"]);
});
}
});
test("the chart read is only for the person it guessed", async () => withEarlyReads(
{ chartView: json({ status: "ok" }), chartViewSubject: PERSON }, async () => {
assert.equal(takeBootChartViewRead("self"), null, "another person gets nothing");
assert.equal(takeBootChartViewRead(PERSON), null, "and the guess is dropped for good");
},
));
test("an early chart read still honours the page's deadline", async () => {
const controller = new AbortController();
const never = new Promise<Response>(() => undefined);
const pending = fetchChartView({ subjectId: PERSON, signal: controller.signal, early: never });
const original = globalThis.fetch;
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
if (init?.signal?.aborted) throw init.signal.reason;
return new Response("{}");
}) as typeof fetch;
try {
controller.abort(new DOMException("deadline", "TimeoutError"));
await assert.rejects(pending, (error: unknown) => (error as DOMException).name === "TimeoutError");
} finally {
globalThis.fetch = original;
}
});
test("the synastry page sends its four reads in one round", async () => {
const harness = createClientLifecycleHarness();
const original = globalThis.fetch;
const pending: string[] = [];
globalThis.fetch = ((input: RequestInfo | URL) => {
pending.push(String(input));
return new Promise<Response>(() => undefined);
}) as typeof fetch;
try {
await harness.render(createElement(SidebarProvider, null, createElement(SynastryPicker)));
for (let tick = 0; tick < 5; tick += 1) await harness.idle();
const reads = ["/api/product-access", "/api/account", "/api/chart-profiles", "/api/reports?reportType=synastry_full&limit=10"];
for (const path of reads) assert.ok(pending.includes(path), `${path} goes out before any answer (got ${pending.join(", ")})`);
} finally {
await harness.close();
globalThis.fetch = original;
}
});
+4 -1
View File
@@ -164,7 +164,10 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
assert.match(picker, /compatibility === false/);
assert.match(picker, /合盘服务暂未开放/);
assert.match(picker, /合盘服务暂时连不上,请稍后再试/);
assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("fetchAccount()"));
// 原值:账户在开关结果之后才去读(setAccess("closed") 早于 fetchAccount())
// 新值:账户与开关同时去读,但开关判定仍在用到账户之前,关着时照旧不画选人区
// 原因:issue #127 合盘页几条互不依赖的请求同一轮发出
assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("unwrap(await accountRead)"));
const pending = renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SynastryPicker)));
// 原值:开关未返回时静态 HTML 有副标题「看这段感情往后的走向,和两人之间的助力与考验。」
// 新值:这段副标题不在页面上;未返回时仍不出现「开始合盘」和「第一个人」