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:
co-authored by
Claude Opus 5.5
parent
16fcb8c289
commit
06d0c7dc37
@@ -1,5 +1,11 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-28 — 星盘页、档案页、合盘页打开更快
|
||||
|
||||
- 直接打开或刷新星盘页时,账户、人物和这张盘在网页一到就同时开始读取,不再等页面代码跑完、账户回来以后才开始算盘。
|
||||
- 档案页、合盘页不再把账户和人物列表各读两遍;合盘页几条互不相关的读取改为同时发出。
|
||||
- 看到的内容不变。Skill 版本不变(BUG-1478,issue #127)。
|
||||
|
||||
## 2026-10-28 — 用链接打开对话更快
|
||||
|
||||
- 从分享链接、侧栏历史或刷新打开某条对话时,对话内容和账户同时开始读取,少等一轮(手机上约 0.15~0.4 秒)。打开别人的或已删除的对话,提示和以前一样。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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) : []);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, "暂时无法读取账户信息"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 有副标题「看这段感情往后的走向,和两人之间的助力与考验。」
|
||||
// 新值:这段副标题不在页面上;未返回时仍不出现「开始合盘」和「第一个人」
|
||||
|
||||
Reference in New Issue
Block a user