fix(web): recover the home screen when the bundle never runs

This commit is contained in:
jesse-ux
2026-09-22 12:13:17 +08:00
parent 6ac61be07d
commit 337d820076
18 changed files with 882 additions and 22 deletions
+3
View File
@@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner";
import { StaleBuildGuard } from "@/components/stale-build-guard";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import { ViewportScrollLock } from "@/components/viewport-scroll-lock";
import { firstPaintFallbackBootScript } from "@/lib/first-paint-fallback";
import { themePreferenceBootScript } from "@/lib/theme-preference";
// Vendored Inter latin variable (OFL). Do not switch back to next/font/google;
@@ -54,6 +55,8 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
{/* Synchronous on purpose: a pinned theme must be on <html> before the
first paint, or the page flashes the other theme on every load. */}
<script dangerouslySetInnerHTML={{ __html: themePreferenceBootScript }} />
{/* Classic script, not a module: it has to run when the client bundle never does. */}
<script dangerouslySetInnerHTML={{ __html: firstPaintFallbackBootScript() }} />
{enableReactDevTools && (
<>
<Script
@@ -80,6 +80,12 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
visibleSessions,
} = params;
useEffect(() => {
// The inline dead-screen script stands down only after the app has revealed.
if (!hydrated || typeof document === "undefined") return;
document.documentElement.dataset.hydrated = "1";
}, [hydrated]);
useEffect(() => {
if (!hydrated || !account) {
registerShellControls(null);
+129
View File
@@ -0,0 +1,129 @@
/**
* Classic inline script for the home loading screen.
*
* It has to run when the client bundle never runs, so the generated string is
* ES5: no modules, no React, no reload until the button is clicked.
* The timer is longer than the 8000ms account budget plus the 4000ms prepare
* reveal, so a normal hydrate does not flash this copy.
*/
import {
HOME_BOOTSTRAP_FAILURE,
sanitizeBootstrapBuildId,
} from "./home-bootstrap-failure.ts";
export const FIRST_PAINT_FALLBACK_TIMEOUT_MS = 13_000;
export const FIRST_PAINT_FALLBACK_TITLE = "这个页面没能加载完";
export const FIRST_PAINT_FALLBACK_BODY =
"网络中断或浏览器版本过旧都会这样。可以重新加载试一次;如果反复出现,请在 Safari 设置里清除本站数据,或把系统升级到 iOS 16.4 以上。";
export const FIRST_PAINT_FALLBACK_ACTION = "重新加载";
function jsDoubleQuoted(value: string): string {
let escaped = "";
for (let index = 0; index < value.length; index += 1) {
const char = value.charAt(index);
const code = value.charCodeAt(index);
if (char === "\\") escaped += "\\\\";
else if (char === "\"") escaped += "\\\"";
else if (char === "\n") escaped += "\\n";
else if (char === "\r") escaped += "\\r";
else if (char === "<") escaped += "\\u003c";
else if (code === 0x2028 || code === 0x2029) escaped += `\\u${code.toString(16)}`;
else escaped += char;
}
return `"${escaped}"`;
}
export function firstPaintFallbackBootScript(buildId?: string): string {
const safeBuild = sanitizeBootstrapBuildId(buildId ?? process.env.NEXT_PUBLIC_GIT_COMMIT);
const timeout = String(FIRST_PAINT_FALLBACK_TIMEOUT_MS);
const hydrateTimeout = jsDoubleQuoted(HOME_BOOTSTRAP_FAILURE.hydrateTimeout);
const chunk404 = jsDoubleQuoted(HOME_BOOTSTRAP_FAILURE.chunk404);
const chunkParse = jsDoubleQuoted(HOME_BOOTSTRAP_FAILURE.chunkParse);
const staleCache = jsDoubleQuoted(HOME_BOOTSTRAP_FAILURE.staleCache);
const weakNetwork = jsDoubleQuoted(HOME_BOOTSTRAP_FAILURE.weakNetwork);
return `(function () {
var started = Date.now();
var buildId = ${jsDoubleQuoted(safeBuild)};
var category = ${hydrateTimeout};
function fromCache(source) {
try {
if (!source || !window.performance || !window.performance.getEntriesByName) return false;
var entries = window.performance.getEntriesByName(source);
var i;
for (i = 0; i < entries.length; i++) {
var entry = entries[i];
if (entry && entry.decodedBodySize > 0 && entry.transferSize === 0) return true;
}
} catch (ignore) {}
return false;
}
function classify(message, source) {
var text = String(message || "");
var from = String(source || "");
var combined = text + " " + from;
if (/SyntaxError|Unexpected token|Invalid regular expression/i.test(text)) {
return fromCache(from) ? ${staleCache} : ${chunkParse};
}
if (/ChunkLoadError|Loading chunk|Failed to fetch dynamically imported module|Failed to load/i.test(combined) || /\\/_next\\/static\\//.test(from)) {
if (fromCache(from)) return ${staleCache};
if (navigator && navigator.onLine === false) return ${weakNetwork};
return ${chunk404};
}
return "";
}
window.addEventListener("error", function (event) {
var message = "";
var source = "";
if (event) {
message = event.message || "";
source = event.filename || "";
if (!source && event.target && event.target.src) source = event.target.src;
}
var next = classify(message, source);
if (next) category = next;
}, true);
window.setTimeout(function () {
var root = document.documentElement;
if (!root || root.getAttribute("data-hydrated") === "1") return;
var loading = document.querySelector(".app-loading");
if (!loading) return;
if ((category === ${hydrateTimeout} || category === ${chunk404}) && navigator && navigator.onLine === false) {
category = ${weakNetwork};
}
var slot = loading.querySelector(".app-loading-content");
if (!slot) return;
if (loading.getAttribute("data-bootstrap-fallback") === "1") return;
loading.setAttribute("data-bootstrap-fallback", "1");
loading.setAttribute("aria-busy", "false");
if (loading.className.indexOf("app-loading-error") === -1) {
loading.className += " app-loading-error";
}
slot.setAttribute("role", "alert");
slot.innerHTML = "";
var title = document.createElement("strong");
title.appendChild(document.createTextNode(${jsDoubleQuoted(FIRST_PAINT_FALLBACK_TITLE)}));
var body = document.createElement("span");
body.appendChild(document.createTextNode(${jsDoubleQuoted(FIRST_PAINT_FALLBACK_BODY)}));
var actions = document.createElement("div");
actions.className = "app-loading-actions";
var button = document.createElement("button");
button.className = "button-primary";
button.type = "button";
button.appendChild(document.createTextNode(${jsDoubleQuoted(FIRST_PAINT_FALLBACK_ACTION)}));
button.onclick = function () { window.location.reload(); };
actions.appendChild(button);
slot.appendChild(title);
slot.appendChild(body);
slot.appendChild(actions);
var duration = Date.now() - started;
root.setAttribute("data-bootstrap-failure", category);
root.setAttribute("data-bootstrap-build", buildId);
root.setAttribute("data-bootstrap-duration", String(duration));
window.__jyotishaBootstrapObservation = { category: category, buildId: buildId, durationMs: duration };
}, ${timeout});
})();`;
}
@@ -0,0 +1,87 @@
/**
* Home bootstrap failures are not one "the API is slow" bucket.
*
* `api-error` is the in-bundle account/session path (HTTP status or the 8s
* cloud timeout). The other categories are what the inline dead-screen script
* can see before that bundle runs. Observation records only category, build
* id, and duration — never a payload, exception string, or birth data.
*/
export const HOME_BOOTSTRAP_FAILURE = {
hydrateTimeout: "hydrate-timeout",
chunk404: "chunk-404",
chunkParse: "chunk-parse",
staleCache: "stale-cache",
weakNetwork: "weak-network",
apiError: "api-error",
} as const;
export type HomeBootstrapFailureCategory =
(typeof HOME_BOOTSTRAP_FAILURE)[keyof typeof HOME_BOOTSTRAP_FAILURE];
export type HomeBootstrapFailureInput = {
/** `false` when the browser reports offline. `null` when the signal is absent. */
online: boolean | null;
scriptMessage?: string;
scriptSource?: string;
/** A failed script whose resource timing says it was served from cache. */
resourceFromCache?: boolean;
/** HTTP status from an account/session/model request the bundle actually made. */
apiStatus?: number | null;
/** Bundle-side cloud timeout (page.tsx 8000ms), not the dead-screen timer. */
apiTimedOut?: boolean;
};
const PARSE_FAILURE = /SyntaxError|Unexpected token|Invalid regular expression/i;
const CHUNK_LOAD_FAILURE = /ChunkLoadError|Loading chunk|Failed to fetch dynamically imported module|Failed to load/i;
export function sanitizeBootstrapBuildId(value: string | null | undefined): string {
if (typeof value !== "string") return "unknown";
const trimmed = value.trim();
return /^[0-9a-fA-F]{7,64}$/.test(trimmed) ? trimmed : "unknown";
}
export function classifyHomeBootstrapFailure(
input: HomeBootstrapFailureInput,
): HomeBootstrapFailureCategory {
if (typeof input.apiStatus === "number" && input.apiStatus >= 400) {
return HOME_BOOTSTRAP_FAILURE.apiError;
}
const message = input.scriptMessage ?? "";
const source = input.scriptSource ?? "";
const parseFailed = PARSE_FAILURE.test(message);
const chunkFailed = CHUNK_LOAD_FAILURE.test(`${message} ${source}`) || /\/_next\/static\//.test(source);
if (parseFailed) {
return input.resourceFromCache
? HOME_BOOTSTRAP_FAILURE.staleCache
: HOME_BOOTSTRAP_FAILURE.chunkParse;
}
if (chunkFailed) {
if (input.resourceFromCache) return HOME_BOOTSTRAP_FAILURE.staleCache;
if (input.online === false) return HOME_BOOTSTRAP_FAILURE.weakNetwork;
return HOME_BOOTSTRAP_FAILURE.chunk404;
}
if (input.online === false) return HOME_BOOTSTRAP_FAILURE.weakNetwork;
if (input.apiTimedOut) return HOME_BOOTSTRAP_FAILURE.apiError;
return HOME_BOOTSTRAP_FAILURE.hydrateTimeout;
}
export type BootstrapObservation = {
category: HomeBootstrapFailureCategory;
buildId: string;
durationMs: number;
};
export function buildBootstrapObservation(
category: HomeBootstrapFailureCategory,
buildId: string,
durationMs: number,
): BootstrapObservation {
const safeDuration = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : 0;
return {
category,
buildId: sanitizeBootstrapBuildId(buildId),
durationMs: safeDuration,
};
}
@@ -3,6 +3,7 @@ import {
computeEvidenceHash,
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import { splitAfterSentencePunctuation } from "./sentence-split.ts";
import {
BLOCKED_DETERMINISTIC_PHRASES,
CHART_IDS,
@@ -2892,8 +2893,10 @@ export function findForbiddenDeterministicClaims(textValue: string): ForbiddenCl
}
export function splitSentences(textValue: string): string[] {
return textValue
.split(/(?<=[。!?!?;;])\s*|\n+/u)
return splitAfterSentencePunctuation(textValue, "。!?!?;;", {
consumeFollowingWhitespace: true,
splitOnNewlines: true,
})
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
@@ -6,6 +6,7 @@
* no unique-minute claim.
*/
import { splitAfterSentencePunctuation } from "../sentence-split.ts";
import { PROBE_EXPLAIN_COPY } from "./v9/probe-explain.ts";
import { STEP_STATE_COPY } from "./v9/step-state.ts";
import {
@@ -66,12 +67,11 @@ export function openingSpokenBody(range: readonly [string, string] | null): stri
}
const OPENING_YEAR = /(?:19|20)\d{2}/;
const OPENING_SENTENCE = /(?<=[。!?])/;
export function isAcceptableOpeningBody(body: string): boolean {
const spoken = body.trim();
if (!spoken || OPENING_YEAR.test(spoken)) return false;
const sentences = spoken.split(OPENING_SENTENCE).map((part) => part.trim()).filter(Boolean);
const sentences = splitAfterSentencePunctuation(spoken, "。!?").map((part) => part.trim()).filter(Boolean);
if (sentences.length === 0 || sentences.length > 4) return false;
const hits = OPENING_COLLECT_DOMAINS.filter((domain) => spoken.includes(domain)).length;
return hits >= 5;
@@ -280,8 +280,7 @@ export function withCompareFailedRetryNotice(body: string): string {
export function stripRangeProgressClaims(body: string): string {
const spoken = body.trim();
if (!spoken) return "";
const kept = spoken
.split(/(?<=[。!?])/)
const kept = splitAfterSentencePunctuation(spoken, "。!?")
.map((part) => part.trim())
.filter((part) => part && !/收窄|范围从/.test(part));
return kept.join("");
@@ -511,8 +510,7 @@ export function engineMeaningToDisplayCopy(meaning: string | null | undefined):
(text, [pattern, next]) => text.replace(pattern, next),
meaning,
);
return replaced
.split(/(?<=[。!?;;])\s*/)
return splitAfterSentencePunctuation(replaced, "。!?;;", { consumeFollowingWhitespace: true })
.filter((sentence) => sentence.trim() && !INSTRUCTION_TONE_SENTENCE.test(sentence))
.join("")
.replace(/\s+/g, " ")
@@ -6,6 +6,7 @@
* checked after adopt. It never changes sessionOutcome or focus.
*/
import { splitAfterSentencePunctuation } from "../../sentence-split.ts";
import type { RectificationDecision } from "../core/rectification-decision.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
@@ -272,7 +273,9 @@ export function validateAdoptNarration(
const trimmed = text.trim().replace(/\s+/g, " ");
if (!trimmed) return { ok: false, reason: "empty" };
if (trimmed.length > ADOPT_NARRATION_MAX_CHARS) return { ok: false, reason: "length" };
const sentences = trimmed.split(/(?<=[。!?!?.])/).map((item) => item.trim()).filter(Boolean);
const sentences = splitAfterSentencePunctuation(trimmed, "。!?!?.")
.map((item) => item.trim())
.filter(Boolean);
if (sentences.some((item) => /[??]$/.test(item))) {
return { ok: false, reason: "question" };
}
@@ -4,9 +4,14 @@
* “did this body already ask”.
*/
const SENTENCE_SPLIT = /(?<=[。!??\n])/;
import { splitAfterSentencePunctuation } from "../../sentence-split.ts";
const NARRATIVE_SENTENCE = /范围|记下|对照|\d{1,2}:\d{2}/;
function splitCollectBody(body: string): string[] {
return splitAfterSentencePunctuation(body, "。!??\n");
}
export const EVIDENCE_VALUE_JUDGMENT_PHRASES = [
"很有帮助",
"很有价值",
@@ -15,7 +20,7 @@ export const EVIDENCE_VALUE_JUDGMENT_PHRASES = [
] as const;
function spokenSentences(body: string): string[] {
return body.split(SENTENCE_SPLIT).map((part) => part.trim()).filter(Boolean);
return splitCollectBody(body).map((part) => part.trim()).filter(Boolean);
}
export function trimEvidenceTurnBody(body: string, options?: { maxSentences?: number }): string {
@@ -68,7 +73,7 @@ export function stripQuestionSentences(body: string, stem: string): string {
if (!spoken) return "";
const kept: string[] = [];
let dropContinuation = false;
for (const part of spoken.split(SENTENCE_SPLIT)) {
for (const part of splitCollectBody(spoken)) {
const text = part.trim();
if (!text) continue;
if (isQuestionSentence(text, prompt)) {
@@ -1,3 +1,4 @@
import { splitAfterSentencePunctuation } from "../../sentence-split.ts";
import { parseAgentChoiceCopy } from "./choice-card";
import type { V9CaseDossier } from "./tool-service";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
@@ -98,7 +99,7 @@ const MARKER_GROUPS: readonly (readonly string[])[] = [
export function extractSpokenQuestion(spoken: string): string | null {
const parts = spoken
.split(/\n{2,}/)
.flatMap((block) => block.split(/(?<=[。!??])\s*/u))
.flatMap((block) => splitAfterSentencePunctuation(block, "。!??", { consumeFollowingWhitespace: true }))
.map((part) => part.trim())
.filter((part) => part.length > 0 && /[??]/.test(part));
const last = parts.at(-1);
@@ -152,7 +153,7 @@ export function bindSpokenToOpenQuestion(spoken: string, nextQuestion: string |
if (!lock) return spoken.trim();
const ack = spoken
.split(/\n{2,}/)
.flatMap((block) => block.split(/(?<=[。!])\s*/u))
.flatMap((block) => splitAfterSentencePunctuation(block, "。!", { consumeFollowingWhitespace: true }))
.map((part) => part.trim())
.filter((part) => (
part.length > 0
+63
View File
@@ -0,0 +1,63 @@
/**
* Split after sentence punctuation and keep the punctuation on the left piece.
*
* Replaces lookbehind splits. Those are syntax errors on Safari before 16.4,
* so this file must not grow one. A newline inside `punctuation`
* is a kept boundary. `splitOnNewlines` instead discards `\n+` runs. Do not
* combine that option with a newline in `punctuation`.
*/
export type SentenceSplitOptions = {
/** Drop whitespace after the kept punctuation. Same as a trailing `\s*`. */
consumeFollowingWhitespace?: boolean;
/** Discard `\n+` even when the newline is not after punctuation. */
splitOnNewlines?: boolean;
};
function isJsWhitespace(char: string): boolean {
return char.length === 1 && /\s/u.test(char);
}
export function splitAfterSentencePunctuation(
text: string,
punctuation: string,
options?: SentenceSplitOptions,
): string[] {
const consumeFollowingWhitespace = options?.consumeFollowingWhitespace === true;
const splitOnNewlines = options?.splitOnNewlines === true;
const marks = new Set(punctuation);
const parts: string[] = [];
let start = 0;
let index = 0;
while (index < text.length) {
const char = text.charAt(index);
if (marks.has(char)) {
const boundaryEnd = index + 1;
let delimiterEnd = boundaryEnd;
if (consumeFollowingWhitespace) {
while (delimiterEnd < text.length && isJsWhitespace(text.charAt(delimiterEnd))) {
delimiterEnd += 1;
}
}
// A zero-width match at the end of the string does not add a trailing empty piece.
if (delimiterEnd === text.length && delimiterEnd === boundaryEnd) break;
parts.push(text.slice(start, boundaryEnd));
start = delimiterEnd;
index = delimiterEnd;
continue;
}
if (splitOnNewlines && char === "\n") {
let newlineEnd = index + 1;
while (newlineEnd < text.length && text.charAt(newlineEnd) === "\n") newlineEnd += 1;
parts.push(text.slice(start, index));
start = newlineEnd;
index = newlineEnd;
continue;
}
index += 1;
}
parts.push(text.slice(start));
return parts;
}