@molecule/app-ide-react 1.9.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -20
- package/dist/components/ChatPanel.d.ts +11 -5
- package/dist/components/ChatPanel.d.ts.map +1 -1
- package/dist/components/ChatPanel.js +123 -41
- package/dist/components/ChatPanel.js.map +1 -1
- package/dist/components/PreviewPanel.d.ts +1 -1
- package/dist/components/PreviewPanel.d.ts.map +1 -1
- package/dist/components/PreviewPanel.js +286 -32
- package/dist/components/PreviewPanel.js.map +1 -1
- package/dist/components/chat-models-utilities.d.ts +7 -3
- package/dist/components/chat-models-utilities.d.ts.map +1 -1
- package/dist/components/chat-models-utilities.js +65 -9
- package/dist/components/chat-models-utilities.js.map +1 -1
- package/dist/types.d.ts +40 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
|
@@ -59,7 +59,7 @@ import type { PreviewPanelProps } from '../types.js';
|
|
|
59
59
|
* @param props - Component props.
|
|
60
60
|
* @returns The rendered preview panel element.
|
|
61
61
|
*/
|
|
62
|
-
export declare function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }: PreviewPanelProps): JSX.Element;
|
|
62
|
+
export declare function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRestartBackend, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }: PreviewPanelProps): JSX.Element;
|
|
63
63
|
export declare namespace PreviewPanel {
|
|
64
64
|
var displayName: string;
|
|
65
65
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PreviewPanel.d.ts","sourceRoot":"","sources":["../../src/components/PreviewPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAEH,OAAO,EAAE,KAAK,GAAG,EAA4C,MAAM,OAAO,CAAA;AAW1E,OAAO,KAAK,EAAE,iBAAiB,EAAuC,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"PreviewPanel.d.ts","sourceRoot":"","sources":["../../src/components/PreviewPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAEH,OAAO,EAAE,KAAK,GAAG,EAA4C,MAAM,OAAO,CAAA;AAW1E,OAAO,KAAK,EAAE,iBAAiB,EAAuC,MAAM,aAAa,CAAA;AAoWzF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,EAC3B,gBAAgB,EAChB,mBAAmB,EACnB,SAAS,EACT,cAAc,EACd,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,SAAS,EACT,UAAU,EACV,cAAc,EACd,YAAY,EACZ,UAAU,EACV,MAAM,GACP,EAAE,iBAAiB,GAAG,GAAG,CAAC,OAAO,CA81DjC;yBA52De,YAAY"}
|
|
@@ -73,6 +73,23 @@ const ORIENTATION_STORAGE_KEY = 'molecule.ide.preview.orientation';
|
|
|
73
73
|
// --- Polling constants (exponential backoff, never gives up) ---
|
|
74
74
|
/** Initial poll interval when waiting for the server. */
|
|
75
75
|
const POLL_INITIAL_MS = 500;
|
|
76
|
+
/**
|
|
77
|
+
* How long the FIRST-mount server-up poll may run without the server answering
|
|
78
|
+
* before the panel surfaces the actionable "Preview can't load here" notice
|
|
79
|
+
* (ms). The poll itself keeps running underneath — the notice self-clears the
|
|
80
|
+
* moment a poll succeeds and mounts the iframe — so this is honesty, not a
|
|
81
|
+
* give-up: it hands the user Reload / Open-in-new-tab instead of an unbounded
|
|
82
|
+
* spinner. Before this existed the pre-mount poll was the one path with NO
|
|
83
|
+
* actionable escape at all: it "never gives up" by design, and the absolute
|
|
84
|
+
* ceiling that should have backstopped it fired once, stood down inside the
|
|
85
|
+
* wake window, and never re-checked — a viewer's fresh page load against a
|
|
86
|
+
* never-answering preview sat on "Loading preview…" indefinitely with no modal
|
|
87
|
+
* (observed 2026-08-31). Sized past a proxy cold-start + dev-server relaunch so
|
|
88
|
+
* a normal slow boot still resolves silently.
|
|
89
|
+
*/
|
|
90
|
+
const PREMOUNT_GIVEUP_MS = 45_000;
|
|
91
|
+
/** Re-check gap for the absolute readiness ceiling when it stands down for a benign hold (ms). */
|
|
92
|
+
const ABSOLUTE_STUCK_RECHECK_MS = 10_000;
|
|
76
93
|
/** Maximum poll interval after backoff. */
|
|
77
94
|
const POLL_MAX_MS = 5000;
|
|
78
95
|
/** Backoff multiplier applied after each failed poll. */
|
|
@@ -102,6 +119,38 @@ const STUCK_BACKOFF_FACTOR = 1.6;
|
|
|
102
119
|
* instead of remounting indefinitely — the old `longRetry()` looped forever.
|
|
103
120
|
*/
|
|
104
121
|
const MAX_RECOVERY_CYCLES = 3;
|
|
122
|
+
/**
|
|
123
|
+
* Max probe-driven automatic recoveries per dead-end episode. Every actionable
|
|
124
|
+
* notice ("Preview can't load here", "the preview is blank") used to be a true
|
|
125
|
+
* dead end: all in-budget retries had already run, and only a human clicking
|
|
126
|
+
* Reload could try again. But the server those retries ran against may simply
|
|
127
|
+
* not be up YET — an E2B resume of a long-paused sandbox pulls its snapshot
|
|
128
|
+
* from cold storage and can take minutes, outliving wake patience AND every
|
|
129
|
+
* retry budget (observed 2026-08-31: a 24h-asleep sandbox woke fine, but the
|
|
130
|
+
* panel had given up and a manual "Reload preview" was needed — which worked,
|
|
131
|
+
* because by then the server was serving). So while a dead-end notice is up
|
|
132
|
+
* over a DEAD document, the panel keeps probing the preview URL and, the
|
|
133
|
+
* moment the server answers, runs exactly the manual-retry path itself. This
|
|
134
|
+
* cap bounds that per episode so a server that answers probes but serves a
|
|
135
|
+
* broken app cannot ping-pong give-up → auto-retry forever; a confirmed render
|
|
136
|
+
* (or a human retry) resets the budget.
|
|
137
|
+
*/
|
|
138
|
+
const MAX_DEAD_END_AUTO_RECOVERS = 3;
|
|
139
|
+
// --- Backend-restart escalation (see PreviewPanelProps.onRestartBackend) ---
|
|
140
|
+
/**
|
|
141
|
+
* How long the app may sit with NO confirmed render before the panel may escalate
|
|
142
|
+
* to a backend dev-server restart (ms). Deliberately generous: it must outlast a
|
|
143
|
+
* heavy cold compile, wake patience, and multiple dead-end reload attempts, so a
|
|
144
|
+
* restart is only ever the answer when reloading has already been tried against a
|
|
145
|
+
* SERVING host and provably changed nothing (the poisoned-module-cache class —
|
|
146
|
+
* a document reload reuses the dev server's immutable `?v=` module URLs, so only
|
|
147
|
+
* a server restart, which mints a new version hash, can clear it).
|
|
148
|
+
*/
|
|
149
|
+
const BACKEND_RESTART_AFTER_MS = 180_000;
|
|
150
|
+
/** Cooldown between backend-restart escalations (ms) — one restart gets one full recovery cycle. */
|
|
151
|
+
const BACKEND_RESTART_COOLDOWN_MS = 600_000;
|
|
152
|
+
/** Cadence of the escalation watchdog's cheap pre-checks (ms). */
|
|
153
|
+
const BACKEND_RESTART_CHECK_MS = 10_000;
|
|
105
154
|
/**
|
|
106
155
|
* Absolute upper bound (ms) on how long the preview may sit on the loading overlay
|
|
107
156
|
* without ever CONFIRMING a render (`molecule:ready`), while not mid-build. A pure
|
|
@@ -346,7 +395,7 @@ async function isServerUp(url, externalSignal) {
|
|
|
346
395
|
* @param props - Component props.
|
|
347
396
|
* @returns The rendered preview panel element.
|
|
348
397
|
*/
|
|
349
|
-
export function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }) {
|
|
398
|
+
export function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRestartBackend, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }) {
|
|
350
399
|
const cm = getClassMap();
|
|
351
400
|
const { state, setUrl, refresh, setDevice, openExternal, recordNavigation, back, forward } = usePreview();
|
|
352
401
|
const iframeRef = useRef(null);
|
|
@@ -476,6 +525,9 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
476
525
|
const pollEpochRef = useRef(0);
|
|
477
526
|
// AbortController for the in-flight server-up probe, so cleanup can cancel it.
|
|
478
527
|
const pollAbortRef = useRef(null);
|
|
528
|
+
// Deadline for the first-mount poll to surface the actionable notice while it
|
|
529
|
+
// keeps polling (see PREMOUNT_GIVEUP_MS). Cleared with the poll chain.
|
|
530
|
+
const pollGiveUpTimerRef = useRef(null);
|
|
479
531
|
const urlRef = useRef(state.url);
|
|
480
532
|
urlRef.current = state.url;
|
|
481
533
|
// The preview's live location (client-side route included), read by the stuck/freeze
|
|
@@ -499,6 +551,17 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
499
551
|
// True once the cap of recovery cycles is hit — stops the loop and shows the
|
|
500
552
|
// themed "Preview can't load here" panel (loop breaker) instead of thrashing.
|
|
501
553
|
const [previewGaveUp, setPreviewGaveUp] = useState(false);
|
|
554
|
+
// Probe-driven automatic recoveries fired this dead-end episode (see
|
|
555
|
+
// MAX_DEAD_END_AUTO_RECOVERS) — reset on a confirmed render or a manual retry.
|
|
556
|
+
const deadEndAutoRecoversRef = useRef(0);
|
|
557
|
+
// --- Backend-restart escalation state (see the escalation watchdog effect) ---
|
|
558
|
+
// When the current "no confirmed render" stretch began. Pushed forward by any
|
|
559
|
+
// confirm-state change, a new load target, and a wake — so the escalation clock
|
|
560
|
+
// only measures genuinely-stuck time, never healthy time or granted patience.
|
|
561
|
+
const notConfirmedSinceRef = useRef(Date.now());
|
|
562
|
+
// One escalation per broken episode; re-armed only by a confirmed render.
|
|
563
|
+
const backendRestartUsedRef = useRef(false);
|
|
564
|
+
const lastBackendRestartAtRef = useRef(0);
|
|
502
565
|
// Mirror iframeReady to a ref so timer callbacks read current value
|
|
503
566
|
const iframeReadyRef = useRef(iframeReady);
|
|
504
567
|
iframeReadyRef.current = iframeReady;
|
|
@@ -620,6 +683,9 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
620
683
|
setBlankPostBuild(false);
|
|
621
684
|
setPreviewGaveUp(false);
|
|
622
685
|
setStuckRetryCount(0);
|
|
686
|
+
// A wake grants a fresh escalation clock too — the relaunching server owes
|
|
687
|
+
// no restart while its own cold path plays out.
|
|
688
|
+
notConfirmedSinceRef.current = Date.now();
|
|
623
689
|
if (neverRendered && urlRef.current) {
|
|
624
690
|
setIframeSrc(withCacheBuster(urlRef.current));
|
|
625
691
|
}
|
|
@@ -659,6 +725,10 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
659
725
|
pollAbortRef.current.abort();
|
|
660
726
|
pollAbortRef.current = null;
|
|
661
727
|
}
|
|
728
|
+
if (pollGiveUpTimerRef.current) {
|
|
729
|
+
clearTimeout(pollGiveUpTimerRef.current);
|
|
730
|
+
pollGiveUpTimerRef.current = null;
|
|
731
|
+
}
|
|
662
732
|
}, []);
|
|
663
733
|
// --- Clear stuck timer ---
|
|
664
734
|
const clearStuckTimer = useCallback(() => {
|
|
@@ -668,7 +738,10 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
668
738
|
}
|
|
669
739
|
}, []);
|
|
670
740
|
// --- Poll until server is up, then mount the iframe ---
|
|
671
|
-
// Uses exponential backoff: POLL_INITIAL_MS → POLL_MAX_MS.
|
|
741
|
+
// Uses exponential backoff: POLL_INITIAL_MS → POLL_MAX_MS. The poll itself never
|
|
742
|
+
// stops, but past PREMOUNT_GIVEUP_MS it surfaces the actionable notice while it
|
|
743
|
+
// keeps polling — the user always has a way out (Reload / Open in new tab), and a
|
|
744
|
+
// late success still mounts + withdraws the notice by itself.
|
|
672
745
|
const startPolling = useCallback((url) => {
|
|
673
746
|
// clearPoll() bumps the epoch + aborts the prior chain; capture the fresh
|
|
674
747
|
// epoch + a new AbortController so THIS chain is the only live one. Any older
|
|
@@ -683,6 +756,21 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
683
756
|
// A (re)load hasn't confirmed content yet — the overlay governs until it does.
|
|
684
757
|
setConfirmedContent(false);
|
|
685
758
|
setBlankPostBuild(false);
|
|
759
|
+
// The poll never stops on its own, so past this deadline it surfaces the
|
|
760
|
+
// actionable notice (Reload / Open in new tab) INSTEAD of an unbounded
|
|
761
|
+
// spinner — and keeps polling underneath; a later success mounts the
|
|
762
|
+
// iframe and withdraws the notice. This was the one preview path with no
|
|
763
|
+
// escape hatch at all (see PREMOUNT_GIVEUP_MS).
|
|
764
|
+
pollGiveUpTimerRef.current = setTimeout(() => {
|
|
765
|
+
pollGiveUpTimerRef.current = null;
|
|
766
|
+
if (pollEpochRef.current !== epoch)
|
|
767
|
+
return;
|
|
768
|
+
// Deliberate breadcrumb: this state means the preview host never answered a
|
|
769
|
+
// probe for the whole deadline — the console line is the forensic trail for
|
|
770
|
+
// the next live debug of a "stuck on Loading preview" report.
|
|
771
|
+
console.warn(`[preview] server at ${url} has not answered for ${Math.round(PREMOUNT_GIVEUP_MS / 1000)}s — showing the reload notice, still polling`);
|
|
772
|
+
setPreviewGaveUp(true);
|
|
773
|
+
}, PREMOUNT_GIVEUP_MS);
|
|
686
774
|
let interval = POLL_INITIAL_MS;
|
|
687
775
|
const poll = async () => {
|
|
688
776
|
if (pollEpochRef.current !== epoch || urlRef.current !== url)
|
|
@@ -691,6 +779,11 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
691
779
|
if (pollEpochRef.current !== epoch || urlRef.current !== url)
|
|
692
780
|
return;
|
|
693
781
|
if (up) {
|
|
782
|
+
if (pollGiveUpTimerRef.current) {
|
|
783
|
+
clearTimeout(pollGiveUpTimerRef.current);
|
|
784
|
+
pollGiveUpTimerRef.current = null;
|
|
785
|
+
}
|
|
786
|
+
setPreviewGaveUp(false);
|
|
694
787
|
setEverLoaded(true);
|
|
695
788
|
// Atomic remount (fresh <iframe> element + src in one commit), like the
|
|
696
789
|
// proven manual-reload/navigation paths: if `setUrl` was called more than
|
|
@@ -736,6 +829,8 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
736
829
|
// it patiently (reveal on ready, no premature blank/give-up) until ITS first molecule:ready.
|
|
737
830
|
hasEverRenderedRef.current = false;
|
|
738
831
|
lastLoadAtRef.current = 0;
|
|
832
|
+
// Fresh escalation clock for the new target.
|
|
833
|
+
notConfirmedSinceRef.current = Date.now();
|
|
739
834
|
// A new load target hasn't confirmed content yet.
|
|
740
835
|
setConfirmedContent(false);
|
|
741
836
|
setBlankPostBuild(false);
|
|
@@ -812,8 +907,10 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
812
907
|
if (iframeReady) {
|
|
813
908
|
setFadingOut(true);
|
|
814
909
|
// Content is showing again — tear down the loop-breaker panel so a later good render
|
|
815
|
-
// always clears it
|
|
910
|
+
// always clears it, and refund the dead-end auto-recovery budget (the episode ended
|
|
911
|
+
// in a working preview, so the next outage starts with a full budget).
|
|
816
912
|
setPreviewGaveUp(false);
|
|
913
|
+
deadEndAutoRecoversRef.current = 0;
|
|
817
914
|
// Clear fadingOut when the fade finishes. The overlay's onTransitionEnd is the primary
|
|
818
915
|
// trigger, but it never fires if the overlay unmounts mid-fade, the transition is disabled
|
|
819
916
|
// (prefers-reduced-motion), or in a non-rendering env — so a timer matched to the fade is
|
|
@@ -849,6 +946,19 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
849
946
|
// fallback; do not interrupt a progressing load with a remount.
|
|
850
947
|
if (iframeLoadedRef.current)
|
|
851
948
|
return;
|
|
949
|
+
// A heartbeating document is ALIVE and MID-LOAD: the inline bridge runs
|
|
950
|
+
// from HTML parse, long before a large module graph finishes (a
|
|
951
|
+
// multi-repo import behind a microVM edge takes ~30s for ~250 modules —
|
|
952
|
+
// observed live 2026-08-31). Reloading it aborts every in-flight module
|
|
953
|
+
// fetch, and the next document races onto the aborted entries and
|
|
954
|
+
// memoizes spurious failures — the recovery loop MURDERS the loads it
|
|
955
|
+
// guards ("stuck on Loading preview" while a fresh tab loads fine).
|
|
956
|
+
// Keep watching instead; a dead doc (no heartbeat) still recovers, and
|
|
957
|
+
// window `load` never firing at all is bounded by the ceilings.
|
|
958
|
+
if (Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS) {
|
|
959
|
+
scheduleNext();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
852
962
|
cycleCount += 1;
|
|
853
963
|
setStuckRetryCount(cycleCount);
|
|
854
964
|
if (cycleCount <= MAX_RECOVERY_CYCLES) {
|
|
@@ -876,35 +986,46 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
876
986
|
}, [iframeSrc, iframeReady, previewGaveUp, onPreviewStuck, clearStuckTimer]);
|
|
877
987
|
// --- Absolute readiness ceiling (backstop: never stuck on the overlay forever) ---
|
|
878
988
|
// Independent of every intermediate flag (onLoad / iframeReady / stuck cycles): anchored
|
|
879
|
-
// to the load, it
|
|
880
|
-
// confirmed a render and
|
|
881
|
-
// panel
|
|
882
|
-
// preview spinning forever. Read live refs
|
|
883
|
-
// lands later clears `previewGaveUp` via
|
|
989
|
+
// to the load, it first checks at ABSOLUTE_STUCK_MS and — if the app still hasn't
|
|
990
|
+
// confirmed a render and no benign hold applies — surfaces the actionable loop-breaker
|
|
991
|
+
// panel; a benign hold defers the CHECK, never disarms it. This is the guarantee that no
|
|
992
|
+
// combination of failed sub-paths can leave the preview spinning forever. Read live refs
|
|
993
|
+
// (not stale closures); a `molecule:ready` that lands later clears `previewGaveUp` via
|
|
994
|
+
// the confirm effect, so an early trip is harmless.
|
|
884
995
|
useEffect(() => {
|
|
885
996
|
if (!state.url)
|
|
886
997
|
return;
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
998
|
+
let timer = null;
|
|
999
|
+
const arm = (delay) => {
|
|
1000
|
+
timer = setTimeout(() => {
|
|
1001
|
+
// A revealed or confirmed preview is healthy — this load needs no ceiling.
|
|
1002
|
+
if (confirmedContentRef.current || iframeReadyRef.current)
|
|
1003
|
+
return;
|
|
1004
|
+
// Fire ONLY when still on a bare spinner. iframeReady above is the key gate: the
|
|
1005
|
+
// onLoad grace already revealed a loaded document, so the give-up panel must never
|
|
1006
|
+
// cover it — the panel is reserved for a preview that genuinely never loaded.
|
|
1007
|
+
// A benign hold — an active build, the actionable blank notice already showing, an
|
|
1008
|
+
// ALIVE (heartbeating) cold boot, or a just-woken server's patience window — does
|
|
1009
|
+
// NOT disarm the ceiling: it RE-CHECKS after a beat. The old one-shot timer stood
|
|
1010
|
+
// down permanently on any of these, so a wake window that swallowed the single
|
|
1011
|
+
// firing left a never-answering preview on the spinner forever with no notice
|
|
1012
|
+
// (the "no reload modal ever popped up" report, 2026-08-31).
|
|
1013
|
+
if (isBuildingRef.current ||
|
|
1014
|
+
blankPostBuildRef.current ||
|
|
1015
|
+
Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS ||
|
|
1016
|
+
inWakeWindow()) {
|
|
1017
|
+
arm(ABSOLUTE_STUCK_RECHECK_MS);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
onPreviewStuck?.({ reason: 'load-timeout', url: currentLocationRef.current });
|
|
1021
|
+
setPreviewGaveUp(true);
|
|
1022
|
+
}, delay);
|
|
1023
|
+
};
|
|
1024
|
+
arm(ABSOLUTE_STUCK_MS);
|
|
1025
|
+
return () => {
|
|
1026
|
+
if (timer)
|
|
1027
|
+
clearTimeout(timer);
|
|
1028
|
+
};
|
|
908
1029
|
// Re-armed per load (url change or refresh/back-forward via loadNonce); a successful
|
|
909
1030
|
// render before the ceiling is honored by the live-ref check in the callback.
|
|
910
1031
|
}, [state.url, state.loadNonce, onPreviewStuck, inWakeWindow]);
|
|
@@ -1504,6 +1625,139 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
1504
1625
|
setIframeSrc(withCacheBuster(state.url));
|
|
1505
1626
|
}
|
|
1506
1627
|
}, [state.url]);
|
|
1628
|
+
// A HUMAN retry is a fresh episode — refund the probe-driven budget so the
|
|
1629
|
+
// panel keeps auto-recovering alongside them.
|
|
1630
|
+
const handleUserRetry = useCallback(() => {
|
|
1631
|
+
deadEndAutoRecoversRef.current = 0;
|
|
1632
|
+
handleManualRetry();
|
|
1633
|
+
}, [handleManualRetry]);
|
|
1634
|
+
// --- Dead-end auto-recovery: probe while an actionable notice is up, retry when the server answers ---
|
|
1635
|
+
// Both dead-end notices (the give-up loop-breaker and the blank notice) mean every
|
|
1636
|
+
// in-budget retry already ran — against a server that may simply not have been up YET
|
|
1637
|
+
// (the motivating case: an E2B resume pulling a long-paused snapshot from cold storage
|
|
1638
|
+
// takes minutes; see MAX_DEAD_END_AUTO_RECOVERS). So instead of waiting for a human to
|
|
1639
|
+
// click Reload, keep asking the preview host whether it is serving — sequentially, one
|
|
1640
|
+
// probe at a time, exactly like the steady-state health check — and when it finally
|
|
1641
|
+
// answers, run the manual-retry path automatically. Gated on the document being DEAD
|
|
1642
|
+
// (no recent heartbeat): a heartbeating app behind the blank notice is an app that runs
|
|
1643
|
+
// but renders nothing — an app bug a reload cannot fix, and auto-reloading it would
|
|
1644
|
+
// just flicker the actionable notice the user is reading. isServerUp prefers the
|
|
1645
|
+
// readable /__mol/preview-status (the mlcl.dev proxy reports the UPSTREAM truthfully,
|
|
1646
|
+
// so an edge answering FOR a down sandbox does not read as "up").
|
|
1647
|
+
useEffect(() => {
|
|
1648
|
+
if (!state.url || confirmedContent)
|
|
1649
|
+
return undefined;
|
|
1650
|
+
if (!previewGaveUp && !blankPostBuild)
|
|
1651
|
+
return undefined;
|
|
1652
|
+
const aborter = new AbortController();
|
|
1653
|
+
let timer = null;
|
|
1654
|
+
const tick = async () => {
|
|
1655
|
+
if (aborter.signal.aborted)
|
|
1656
|
+
return;
|
|
1657
|
+
if (deadEndAutoRecoversRef.current >= MAX_DEAD_END_AUTO_RECOVERS)
|
|
1658
|
+
return;
|
|
1659
|
+
if (Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS) {
|
|
1660
|
+
// Alive behind the notice — not our case; re-check later in case it dies.
|
|
1661
|
+
timer = setTimeout(() => void tick(), HEALTH_POLL_MS);
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
const up = await isServerUp(urlRef.current, aborter.signal);
|
|
1665
|
+
if (aborter.signal.aborted)
|
|
1666
|
+
return;
|
|
1667
|
+
if (up) {
|
|
1668
|
+
deadEndAutoRecoversRef.current += 1;
|
|
1669
|
+
handleManualRetry();
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
timer = setTimeout(() => void tick(), HEALTH_POLL_MS);
|
|
1673
|
+
};
|
|
1674
|
+
timer = setTimeout(() => void tick(), HEALTH_POLL_MS);
|
|
1675
|
+
return () => {
|
|
1676
|
+
aborter.abort();
|
|
1677
|
+
if (timer)
|
|
1678
|
+
clearTimeout(timer);
|
|
1679
|
+
};
|
|
1680
|
+
}, [previewGaveUp, blankPostBuild, confirmedContent, state.url, handleManualRetry]);
|
|
1681
|
+
// The escalation clock measures CONTINUOUS unconfirmed time: any change of the
|
|
1682
|
+
// confirm verdict (healthy→broken or broken→healthy) restarts it, so a stretch
|
|
1683
|
+
// of healthy rendering can never count toward a restart, and a confirmed render
|
|
1684
|
+
// re-arms the once-per-episode escalation budget.
|
|
1685
|
+
useEffect(() => {
|
|
1686
|
+
notConfirmedSinceRef.current = Date.now();
|
|
1687
|
+
if (confirmedContent)
|
|
1688
|
+
backendRestartUsedRef.current = false;
|
|
1689
|
+
}, [confirmedContent]);
|
|
1690
|
+
// --- Backend-restart escalation watchdog (see PreviewPanelProps.onRestartBackend) ---
|
|
1691
|
+
// The one recovery a document reload can NEVER perform: when the dev server's
|
|
1692
|
+
// module state is poisoned (observed live 2026-08-31: module failures memoized
|
|
1693
|
+
// against vite's immutable `?v=`-hashed URLs — every reload, manual and
|
|
1694
|
+
// automatic, rebuilt the same broken graph with zero errors anywhere), only a
|
|
1695
|
+
// SERVER restart mints new module URLs. Escalates at most once per broken
|
|
1696
|
+
// episode, and only when every cheaper explanation is exhausted:
|
|
1697
|
+
// • the app has confirmed nothing for BACKEND_RESTART_AFTER_MS straight
|
|
1698
|
+
// (clock reset by confirms, new targets, and wakes — patience is honored),
|
|
1699
|
+
// • at least one dead-end auto-reload already ran (so a plain cold boot,
|
|
1700
|
+
// which never engages the dead-end machinery, can never trip this),
|
|
1701
|
+
// • the server answers probes RIGHT NOW (a down server needs waking/time,
|
|
1702
|
+
// not a restart — the dead-end probe loop owns that case),
|
|
1703
|
+
// • no build is running (the executor owns the sandbox during a turn).
|
|
1704
|
+
// The host may DECLINE (return false: wrong role, hidden tab, sandbox not
|
|
1705
|
+
// running) — nothing is burned and the watchdog may ask again later.
|
|
1706
|
+
useEffect(() => {
|
|
1707
|
+
if (!state.url || !onRestartBackend)
|
|
1708
|
+
return undefined;
|
|
1709
|
+
const aborter = new AbortController();
|
|
1710
|
+
let inFlight = false;
|
|
1711
|
+
const interval = setInterval(() => {
|
|
1712
|
+
if (inFlight)
|
|
1713
|
+
return;
|
|
1714
|
+
inFlight = true;
|
|
1715
|
+
void (async () => {
|
|
1716
|
+
try {
|
|
1717
|
+
if (confirmedContentRef.current || isBuildingRef.current)
|
|
1718
|
+
return;
|
|
1719
|
+
if (deadEndAutoRecoversRef.current < 1)
|
|
1720
|
+
return;
|
|
1721
|
+
if (backendRestartUsedRef.current)
|
|
1722
|
+
return;
|
|
1723
|
+
const now = Date.now();
|
|
1724
|
+
if (now - notConfirmedSinceRef.current < BACKEND_RESTART_AFTER_MS)
|
|
1725
|
+
return;
|
|
1726
|
+
if (now - lastBackendRestartAtRef.current < BACKEND_RESTART_COOLDOWN_MS)
|
|
1727
|
+
return;
|
|
1728
|
+
const up = await isServerUp(urlRef.current, aborter.signal);
|
|
1729
|
+
if (aborter.signal.aborted || !up)
|
|
1730
|
+
return;
|
|
1731
|
+
// Re-check the fast-moving gates after the await.
|
|
1732
|
+
if (confirmedContentRef.current || isBuildingRef.current)
|
|
1733
|
+
return;
|
|
1734
|
+
const outcome = await onRestartBackend();
|
|
1735
|
+
if (aborter.signal.aborted || outcome === false)
|
|
1736
|
+
return;
|
|
1737
|
+
backendRestartUsedRef.current = true;
|
|
1738
|
+
lastBackendRestartAtRef.current = Date.now();
|
|
1739
|
+
notConfirmedSinceRef.current = Date.now();
|
|
1740
|
+
// The restarted server deserves a fresh reload budget so the dead-end
|
|
1741
|
+
// probe (or the wake reload) can bring the recovered app in.
|
|
1742
|
+
deadEndAutoRecoversRef.current = 0;
|
|
1743
|
+
// Deliberate breadcrumb — names the escalation in the console so a
|
|
1744
|
+
// "the preview restarted itself" report is traceable.
|
|
1745
|
+
console.warn('[preview] escalated to a backend dev-server restart — reloads could not produce a render against a serving host');
|
|
1746
|
+
}
|
|
1747
|
+
catch (_error) {
|
|
1748
|
+
// Escalation is strictly best-effort: a failed probe or a host error
|
|
1749
|
+
// must never break the panel; the next check re-evaluates.
|
|
1750
|
+
}
|
|
1751
|
+
finally {
|
|
1752
|
+
inFlight = false;
|
|
1753
|
+
}
|
|
1754
|
+
})();
|
|
1755
|
+
}, BACKEND_RESTART_CHECK_MS);
|
|
1756
|
+
return () => {
|
|
1757
|
+
aborter.abort();
|
|
1758
|
+
clearInterval(interval);
|
|
1759
|
+
};
|
|
1760
|
+
}, [state.url, onRestartBackend]);
|
|
1507
1761
|
// --- Reload a frozen preview ---
|
|
1508
1762
|
// Remounts the iframe (fresh load re-runs the app, clearing the locked thread).
|
|
1509
1763
|
const handleReloadFrozen = useCallback(() => {
|
|
@@ -1571,8 +1825,8 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
1571
1825
|
// Fully opaque + interactive whenever it is standing in for missing content (only the
|
|
1572
1826
|
// ready-then-fading-out state animates to transparent).
|
|
1573
1827
|
const overlaySolid = !iframeReady || blankPostBuild || buildingUnconfirmed;
|
|
1574
|
-
const overlayContent = blankPostBuild ? (_jsx(PreviewBlankNotice, { onReload:
|
|
1575
|
-
loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry:
|
|
1828
|
+
const overlayContent = blankPostBuild ? (_jsx(PreviewBlankNotice, { onReload: handleUserRetry, onOpenExternal: openExternal })) : isBuilding || building ? (_jsx(DefaultLoadingIndicator, { hint: buildingHint, retryCount: stuckRetryCount, onManualRetry: handleUserRetry })) : everLoaded ? ((restartingIndicator ??
|
|
1829
|
+
loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleUserRetry })))) : ((loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleUserRetry }))));
|
|
1576
1830
|
return (_jsxs("div", { className: cm.cn(cm.flex({ direction: 'col' }), cm.h('full'), cm.surface, className), children: [_jsx("div", { className: cm.cn(cm.sp('px', 3), cm.sp('py', 2), cm.shrink0, cm.borderB), children: _jsxs("div", { className: cm.cn(cm.flex({ direction: 'row', align: 'center', gap: 'xs' })), children: [_jsxs("div", { "data-mol-id": "preview-url-field", className: cm.cn(cm.flex({ direction: 'row', align: 'center', gap: 'xs' }), cm.sp('px', 2), cm.surface), style: {
|
|
1577
1831
|
flex: 1,
|
|
1578
1832
|
// On phone-width viewports the field keeps an 80px floor (the
|
|
@@ -1710,7 +1964,7 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
|
|
|
1710
1964
|
zIndex: 2,
|
|
1711
1965
|
padding: '24px',
|
|
1712
1966
|
textAlign: 'center',
|
|
1713
|
-
}, children: [_jsx(Icon, { name: "x-circle", size: 28, className: cm.textMuted, "aria-hidden": "true" }), _jsx("div", { className: cm.cn(cm.textSize('sm')), style: { color: 'var(--mol-color-text, #333)', fontWeight: 600 }, children: t('ide.preview.loadFailed', {}, { defaultValue: "Preview can't load here" }) }), _jsx("div", { className: cm.cn(cm.textSize('xs'), cm.textMuted), style: { maxWidth: '320px' }, children: t('ide.preview.loadFailedHint', {}, { defaultValue: 'Try reloading, or open the preview in a new tab.' }) }), _jsxs("div", { style: { display: 'flex', gap: '8px' }, children: [_jsx("button", { type: "button", "data-mol-id": "preview-load-failed-reload", onClick:
|
|
1967
|
+
}, children: [_jsx(Icon, { name: "x-circle", size: 28, className: cm.textMuted, "aria-hidden": "true" }), _jsx("div", { className: cm.cn(cm.textSize('sm')), style: { color: 'var(--mol-color-text, #333)', fontWeight: 600 }, children: t('ide.preview.loadFailed', {}, { defaultValue: "Preview can't load here" }) }), _jsx("div", { className: cm.cn(cm.textSize('xs'), cm.textMuted), style: { maxWidth: '320px' }, children: t('ide.preview.loadFailedHint', {}, { defaultValue: 'Try reloading, or open the preview in a new tab.' }) }), _jsxs("div", { style: { display: 'flex', gap: '8px' }, children: [_jsx("button", { type: "button", "data-mol-id": "preview-load-failed-reload", onClick: handleUserRetry, className: cm.cn(cm.button({ variant: 'solid', color: 'primary', size: 'sm' }), cm.touchTarget), children: t('ide.preview.reloadPreview', {}, { defaultValue: 'Reload preview' }) }), _jsx("button", { type: "button", "data-mol-id": "preview-load-failed-open", onClick: openExternal, className: cm.cn(cm.button({ variant: 'ghost', size: 'sm' }), cm.touchTarget), children: t('ide.preview.openNewTab', {}, { defaultValue: 'Open in new tab' }) })] })] })), !state.url && (_jsx("div", { className: cm.cn(cm.textMuted, cm.textSize('sm')), style: {
|
|
1714
1968
|
display: 'flex',
|
|
1715
1969
|
alignItems: 'center',
|
|
1716
1970
|
justifyContent: 'center',
|