fix(web): recover the home screen when the bundle never runs
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user