99 WarningIcon ,
1010} from "@phosphor-icons/react" ;
1111import {
12+ currentHeadBuildFailure ,
1213 hasActiveCanvasBuild ,
1314 latestFinishedCanvasBuild ,
1415 publishedCanvasBuild ,
@@ -89,6 +90,10 @@ interface PinnedArtifact {
8990 url : string ;
9091 /** Epoch ms the pinned URL was minted (the builds fetch that produced it). */
9192 mintedAt : number ;
93+ /** The refresh nonce the pin was adopted under, so a remount also re-stamps
94+ * the pin's mint time (otherwise the expiry timer would keep firing on a URL
95+ * that's already been recovered). */
96+ refreshKey : number ;
9297}
9398
9499// A freeform (React-in-iframe) canvas. The rendered output is, in priority
@@ -111,6 +116,16 @@ export function FreeformCanvasView({
111116 const setBrowseVersion = useFreeformChatStore ( ( s ) => s . setBrowseVersion ) ;
112117 const setRuntimeError = useFreeformChatStore ( ( s ) => s . setRuntimeError ) ;
113118
119+ // Protect this thread from LRU eviction while the view is open — a burst of
120+ // background patches must never drop the canvas the user is looking at.
121+ useEffect ( ( ) => {
122+ const store = useFreeformChatStore . getState ( ) ;
123+ store . setThreadMounted ( threadId , true ) ;
124+ return ( ) => {
125+ useFreeformChatStore . getState ( ) . setThreadMounted ( threadId , false ) ;
126+ } ;
127+ } , [ threadId ] ) ;
128+
114129 // Right-hand panel state (persisted minimize + width). `startedTaskId` is a
115130 // local bridge so the composer floats to the side immediately on submit,
116131 // before the canvas record's polled generationTaskId catches up.
@@ -237,25 +252,28 @@ export function FreeformCanvasView({
237252 // Pin the artifact to one signed URL per build: every lifecycle refetch mints
238253 // a fresh URL for the same artifact, and adopting each one would reload the
239254 // iframe on every 2s poll while a build runs. Adopt only when the published
240- // build itself changes — or when a refresh was explicitly requested because
241- // the pinned URL expired. Adjusted during render (not an effect) so the swap
255+ // build itself changes. Adjusted during render (not an effect) so the swap
242256 // can't flash a stale frame.
243257 const [ pinnedArtifact , setPinnedArtifact ] = useState < PinnedArtifact | null > (
244258 null ,
245259 ) ;
246- const wantFreshArtifactUrlRef = useRef ( false ) ;
260+ // A nonce that, when bumped, remounts the artifact frame so it revalidates
261+ // against the live token endpoint (ETag/304 makes this cheap) — the recovery
262+ // path when the pinned URL expired. Remounting, not URL-string compare, is
263+ // what guarantees a wedged iframe actually retries: the token endpoint is the
264+ // authority, and a new URL for the same bucket would be byte-identical.
265+ const [ artifactRefreshKey , setArtifactRefreshKey ] = useState ( 0 ) ;
247266 if ( publishedBuild ?. artifactUrl ) {
248- const shouldAdopt =
267+ const adoptFresh =
249268 ! pinnedArtifact ||
250269 pinnedArtifact . buildId !== publishedBuild . id ||
251- ( wantFreshArtifactUrlRef . current &&
252- pinnedArtifact . url !== publishedBuild . artifactUrl ) ;
253- if ( shouldAdopt ) {
254- wantFreshArtifactUrlRef . current = false ;
270+ pinnedArtifact . refreshKey !== artifactRefreshKey ;
271+ if ( adoptFresh ) {
255272 setPinnedArtifact ( {
256273 buildId : publishedBuild . id ,
257274 url : publishedBuild . artifactUrl ,
258275 mintedAt : buildsUpdatedAt || Date . now ( ) ,
276+ refreshKey : artifactRefreshKey ,
259277 } ) ;
260278 }
261279 } else if ( lifecycle && pinnedArtifact ) {
@@ -280,10 +298,15 @@ export function FreeformCanvasView({
280298 if ( Date . now ( ) - renderedArtifact . mintedAt < ARTIFACT_URL_FRESH_MS ) {
281299 return ;
282300 }
283- wantFreshArtifactUrlRef . current = true ;
284- void queryClient . invalidateQueries ( {
285- queryKey : trpc . dashboards . builds . queryKey ( { id : dashboardId } ) ,
286- } ) ;
301+ // Refetch mints the current bucket's URL (re-checking the token server-
302+ // side even when the browser would reframe from cache), then remount the
303+ // frame so it revalidates against those endpoints. The remount, not a URL
304+ // string change, is what un-wedges a frame whose module fetches hung.
305+ void queryClient
306+ . invalidateQueries ( {
307+ queryKey : trpc . dashboards . builds . queryKey ( { id : dashboardId } ) ,
308+ } )
309+ . then ( ( ) => setArtifactRefreshKey ( ( k ) => k + 1 ) ) ;
287310 } , ARTIFACT_READY_GRACE_MS ) ;
288311 return ( ) => clearTimeout ( timer ) ;
289312 } , [ renderedArtifact , dashboardId , queryClient , trpc ] ) ;
@@ -480,6 +503,7 @@ export function FreeformCanvasView({
480503 ! ! lifecycle &&
481504 lifecycle . builds . length > 0 &&
482505 ( hasActiveCanvasBuild ( lifecycle ) ||
506+ ! ! currentHeadBuildFailure ( lifecycle ) ||
483507 latestFinishedCanvasBuild ( lifecycle ) ?. buildStatus === "failed" ) ;
484508 const showToolbar = interactive || hasBuildSignal ;
485509
@@ -707,6 +731,7 @@ export function FreeformCanvasView({
707731 ) : pinnedArtifact ? (
708732 < Box className = "h-full w-full" >
709733 < BuiltCanvas
734+ key = { `${ pinnedArtifact . buildId } :${ artifactRefreshKey } ` }
710735 artifactUrl = { pinnedArtifact . url }
711736 onDataRequest = { onDataRequest }
712737 onError = { onError }
0 commit comments