@agent-native/core 0.180.0 → 0.180.1-nightly-20260915205111

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.
Files changed (24) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/app/hooks/use-events.ts +11 -2
  3. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +14 -20
  4. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +323 -109
  5. package/corpus/templates/design/app/components/design/ResponsiveInteractBar.tsx +48 -18
  6. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +22 -20
  7. package/corpus/templates/design/app/components/design/design-canvas/LocalNetworkAccessPrompt.tsx +92 -0
  8. package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +41 -0
  9. package/corpus/templates/design/app/pages/DesignEditor.tsx +55 -3
  10. package/corpus/templates/design/changelog/2026-09-15-fixed-padding-indicator-tick-marks-so-they-render-at-the-sam.md +6 -0
  11. package/dist/collab/awareness.d.ts +2 -2
  12. package/dist/collab/routes.d.ts +1 -1
  13. package/dist/collab/struct-routes.d.ts +1 -1
  14. package/dist/file-upload/actions/upload-image.d.ts +3 -3
  15. package/dist/notifications/routes.d.ts +3 -3
  16. package/dist/observability/routes.d.ts +3 -3
  17. package/dist/org/handlers.d.ts +1 -1
  18. package/dist/progress/routes.d.ts +1 -1
  19. package/dist/server/core-routes-plugin.d.ts +1 -1
  20. package/dist/vite/sentry-source-maps.js +12 -20
  21. package/docs/content/template-plan-automations.mdx +30 -30
  22. package/docs/content/template-plan-developers.mdx +10 -10
  23. package/docs/content/template-plan-review-workflow.mdx +2 -2
  24. package/package.json +3 -3
@@ -107,12 +107,14 @@ import {
107
107
  getEmbeddedIframeBackgroundColor,
108
108
  } from "./design-canvas/embedded-frame";
109
109
  import {
110
+ classifyBridgeRegistrationFailure,
110
111
  getDesignCanvasIframeSandbox,
111
112
  getSnapshotRetryDelayMs,
112
113
  resolveLiveEditPreviewUrl,
113
114
  sanitizeLocalhostSourceSnapshotHtml,
114
115
  shouldFetchExternalSourceSnapshot,
115
116
  shouldUseIframeLoadReadyFallback,
117
+ type BridgeRegistrationFailureKind,
116
118
  } from "./design-canvas/external-preview";
117
119
  import { isOsFileDragEvent } from "./design-canvas/file-drop";
118
120
  import {
@@ -130,6 +132,7 @@ import {
130
132
  type EmbeddedCanvasPanSession,
131
133
  } from "./design-canvas/iframe-pan";
132
134
  import { withLocalRuntimes } from "./design-canvas/local-runtime";
135
+ import { LocalNetworkAccessPrompt } from "./design-canvas/LocalNetworkAccessPrompt";
133
136
  import type { MotionTrackWire } from "./design-canvas/motion-types";
134
137
  import {
135
138
  PENDING_TEXT_EDIT_TIMEOUT_MS,
@@ -1706,12 +1709,55 @@ export function DesignCanvas({
1706
1709
  // "Preparing live editor..." with no explanation and no way to recover
1707
1710
  // short of reloading the whole page.
1708
1711
  const bridgeRegistrationRetryAttemptRef = useRef(0);
1712
+ const bridgeRegistrationRetryTimerRef = useRef<number | undefined>(undefined);
1713
+ // Two registration attempts can be in flight at once — the automatic
1714
+ // effect-driven one and a manually-triggered "Connect" click (see
1715
+ // attemptBridgeRegistration/handleConnectLocalNetworkAccess below) — and
1716
+ // they can resolve out of order. Only the most recent attempt's result may
1717
+ // ever be written to state, or an earlier failure resolving after a later
1718
+ // success would incorrectly flip the UI back to "still blocked".
1719
+ const bridgeRegistrationAttemptGenerationRef = useRef(0);
1709
1720
  const [bridgeRegistrationRetryNonce, setBridgeRegistrationRetryNonce] =
1710
1721
  useState(0);
1722
+ // Scoped strictly to the registration fetch() itself failing — drives
1723
+ // externalPreviewUrl's raw-URL fallback and the floating
1724
+ // LocalNetworkAccessPrompt card below. Deliberately separate from
1725
+ // bridgeConnectionLostError: a fetch failure and "the live document never
1726
+ // confirmed ready after a successful registration" (handleSuspectedBridge
1727
+ // Restart's destructive paths) are different failure modes — the latter
1728
+ // proves the bridge WAS reachable, so a permission-flavored "maybe you need
1729
+ // to grant local network access" message would be actively misleading
1730
+ // there, and unlike a fetch failure there's no still-reachable raw
1731
+ // dev-server document to fall back to showing (the live document itself is
1732
+ // what stopped responding).
1711
1733
  const [bridgeRegistrationError, setBridgeRegistrationError] = useState<{
1712
1734
  bridgeKey: string;
1713
1735
  message: string;
1714
1736
  } | null>(null);
1737
+ // handleSuspectedBridgeRestart's destructive terminal states (retry budget
1738
+ // exhausted, or /health itself confirms the process is unreachable) keep
1739
+ // their original full-cover blocking card and retry action, untouched by
1740
+ // the raw-fallback/floating-card behavior above — see
1741
+ // bridgeRegistrationError's comment for why these must not share state.
1742
+ const [bridgeConnectionLostError, setBridgeConnectionLostError] = useState<{
1743
+ bridgeKey: string;
1744
+ message: string;
1745
+ } | null>(null);
1746
+ // Distinguishes "Chrome's Local Network Access permission is blocking this
1747
+ // request" from "the dev server is genuinely down" — see
1748
+ // classifyBridgeRegistrationFailure. Drives which copy/icon the floating
1749
+ // LocalNetworkAccessPrompt card shows; null while unclassified or resolved.
1750
+ const [bridgeRegistrationFailureKind, setBridgeRegistrationFailureKind] =
1751
+ useState<BridgeRegistrationFailureKind | null>(null);
1752
+ // Dismissing the floating connect card is per-script-revision: keyed by the
1753
+ // liveEditBridgeKey it was shown for, so a genuinely new script/mode change
1754
+ // re-surfaces the card instead of leaving it permanently dismissed.
1755
+ const [
1756
+ localNetworkAccessDismissedForKey,
1757
+ setLocalNetworkAccessDismissedForKey,
1758
+ ] = useState<string | null>(null);
1759
+ const [connectingLocalNetworkAccess, setConnectingLocalNetworkAccess] =
1760
+ useState(false);
1715
1761
  // Cache of the bridgeInstanceId returned by the client's LAST successful
1716
1762
  // /live-edit-bridge registration POST (see the registration effect below).
1717
1763
  // Compared against a later /health probe's bridgeInstanceId to tell "the
@@ -1762,12 +1808,13 @@ export function DesignCanvas({
1762
1808
  bridgeKey: string;
1763
1809
  message: string;
1764
1810
  } | null>(null);
1765
- // Set on unmount so the async /health probe (and its escalation re-arm
1766
- // timer) never touches state after this component is gone.
1767
- const isUnmountedRef = useRef(false);
1811
+ // Clears the escalation re-arm timer on unmount so it can't fire and
1812
+ // schedule a fetch after this component is gone. (A stray fetch would be a
1813
+ // no-op anyway — every /health probe checkpoint is generation-guarded via
1814
+ // bridgeRegistrationAttemptGenerationRef, see handleSuspectedBridgeRestart
1815
+ // — but there's no reason to let the timer fire in the first place.)
1768
1816
  useEffect(
1769
1817
  () => () => {
1770
- isUnmountedRef.current = true;
1771
1818
  if (liveEditSameInstanceRearmTimerRef.current !== undefined) {
1772
1819
  window.clearTimeout(liveEditSameInstanceRearmTimerRef.current);
1773
1820
  liveEditSameInstanceRearmTimerRef.current = undefined;
@@ -1902,8 +1949,20 @@ export function DesignCanvas({
1902
1949
  const usesLiveEditInjectedBridge =
1903
1950
  sourceType === "localhost" &&
1904
1951
  Boolean(bridgeUrl && previewToken && rawExternalPreviewUrl);
1952
+ // Hoisted above usesLiveEditEditorBridge (rather than declared next to
1953
+ // externalPreviewUrl/usingRawFallbackPreview below, which reuse it) because
1954
+ // a failed registration's raw-URL fallback document has no injected editor
1955
+ // bridge script and can never answer an editor command or post the ready
1956
+ // handshake — usesLiveEditEditorBridge must already reflect that, or the
1957
+ // still-"editable" chrome above it (selection, inspector, hover) lets the
1958
+ // user attempt edits that silently queue forever against a frame that will
1959
+ // never respond (see postOneShotBridgeMessage's queueing above).
1960
+ const bridgeRegistrationFailedForCurrentKey =
1961
+ bridgeRegistrationError?.bridgeKey === liveEditBridgeKey;
1905
1962
  const usesLiveEditEditorBridge =
1906
- usesLiveEditInjectedBridge && includeLiveEditEditorChrome;
1963
+ usesLiveEditInjectedBridge &&
1964
+ includeLiveEditEditorChrome &&
1965
+ !bridgeRegistrationFailedForCurrentKey;
1907
1966
  const effectiveRegisteredLiveEditBridgeKey =
1908
1967
  registeredLiveEditBridgeKey ??
1909
1968
  (hasRecentLiveEditRegistration(registrationHandoffKey)
@@ -2001,9 +2060,25 @@ export function DesignCanvas({
2001
2060
  // registration succeeds, the one real proxied document mounts directly.
2002
2061
  // A viewer with no previewToken has no bridge to wait for, so it loads the
2003
2062
  // dev server directly rather than degrading to a snapshot.
2063
+ //
2064
+ // A FAILED registration (most commonly Chrome's Local Network Access
2065
+ // permission blocking the fetch — see classifyBridgeRegistrationFailure)
2066
+ // falls back the same way: the dev server itself is still reachable via a
2067
+ // plain iframe navigation (unlike fetch/XHR, navigations aren't subject to
2068
+ // that permission check), so showing it read-only beats hiding a working
2069
+ // app behind an indefinite loading state. LocalNetworkAccessPrompt offers
2070
+ // the way to actually enable editing from here.
2071
+ const usingRawFallbackPreview =
2072
+ usesLiveEditInjectedBridge &&
2073
+ !liveEditExternalPreviewUrl &&
2074
+ bridgeRegistrationFailedForCurrentKey;
2004
2075
  const externalPreviewUrl =
2005
2076
  liveEditExternalPreviewUrl ??
2006
- (usesLiveEditInjectedBridge ? null : rawExternalPreviewUrl);
2077
+ (usesLiveEditInjectedBridge
2078
+ ? bridgeRegistrationFailedForCurrentKey
2079
+ ? rawExternalPreviewUrl
2080
+ : null
2081
+ : rawExternalPreviewUrl);
2007
2082
  const runtimeVerificationUrl = useMemo(() => {
2008
2083
  if (!runtimeVerificationRequest || !externalPreviewUrl) return null;
2009
2084
  return externalPreviewUrl;
@@ -2145,8 +2220,149 @@ export function DesignCanvas({
2145
2220
  // null forever (console.warn only, no retry, no error UI), pinning
2146
2221
  // waitingForLiveEditBridge true with no way to recover short of a full
2147
2222
  // page reload.
2223
+ const scheduleBridgeRegistrationRetry = useCallback(() => {
2224
+ const delay = getSnapshotRetryDelayMs(
2225
+ bridgeRegistrationRetryAttemptRef.current,
2226
+ );
2227
+ bridgeRegistrationRetryAttemptRef.current += 1;
2228
+ bridgeRegistrationRetryTimerRef.current = window.setTimeout(() => {
2229
+ setBridgeRegistrationRetryNonce((nonce) => nonce + 1);
2230
+ }, delay);
2231
+ }, []);
2232
+ // Invalidates any registration attempt still in flight when THIS effect
2233
+ // instance unmounts — deliberately not a one-way "isUnmounted" flag: that
2234
+ // shape never resets, so under StrictMode's dev-only mount→cleanup→mount
2235
+ // replay it would stay stuck true after the first (intentionally
2236
+ // discarded) cleanup and permanently block every later, genuinely-live
2237
+ // attempt. Bumping the generation counter here instead only invalidates
2238
+ // the specific attempt that was in
2239
+ // flight at THIS cleanup; the next mount's own attempt captures a fresh
2240
+ // generation and is unaffected.
2241
+ useEffect(
2242
+ () => () => {
2243
+ bridgeRegistrationAttemptGenerationRef.current += 1;
2244
+ },
2245
+ [],
2246
+ );
2247
+ // Single source of truth for a registration attempt, shared by the
2248
+ // automatic effect below and the manual "Connect" button (see
2249
+ // handleConnectLocalNetworkAccess) — see bridgeRegistrationAttemptGeneration
2250
+ // Ref's comment for why a shared, generation-guarded function is required
2251
+ // instead of each caller firing its own independent fetch.
2252
+ // Returns true/false for a definite, still-applicable outcome, or null when
2253
+ // a newer attempt (effect-driven or manual) has already superseded this
2254
+ // one — callers must treat null as "nothing to do", not as a failure, or a
2255
+ // stale attempt could schedule a redundant retry after a later attempt
2256
+ // already succeeded.
2257
+ const attemptBridgeRegistration = useCallback(async (): Promise<
2258
+ boolean | null
2259
+ > => {
2260
+ if (!usesLiveEditInjectedBridge || !bridgeUrl || !previewToken) {
2261
+ return null;
2262
+ }
2263
+ const generation = ++bridgeRegistrationAttemptGenerationRef.current;
2264
+ // Unmount is covered by the dedicated cleanup-only effect above, which
2265
+ // bumps this same counter — a manual Connect click's fetch has no effect
2266
+ // cleanup of its own to cancel it, but that effect's bump still
2267
+ // invalidates it the same way a superseded attempt is invalidated.
2268
+ const isCurrent = () =>
2269
+ bridgeRegistrationAttemptGenerationRef.current === generation;
2270
+ // A fresh attempt — whether auto-retry or a manual Connect click,
2271
+ // including one retried from the destructive bridgeConnectionLostError
2272
+ // card's own Retry button (see handleConnectLocalNetworkAccess) — means
2273
+ // we're no longer in "connection lost, needs a click" limbo. Clear it now
2274
+ // rather than only on success/failure, or the full-cover destructive card
2275
+ // stays visible (it takes priority in the overlay below) even once this
2276
+ // attempt resolves as an ordinary registration-fetch failure instead.
2277
+ setBridgeConnectionLostError(null);
2278
+ const endpoint = new URL("/live-edit-bridge", bridgeUrl).toString();
2279
+ try {
2280
+ const response = await fetch(endpoint, {
2281
+ method: "POST",
2282
+ headers: {
2283
+ "content-type": "application/json",
2284
+ "x-design-preview-token": previewToken,
2285
+ },
2286
+ body: JSON.stringify({
2287
+ script: liveEditBridgeScript,
2288
+ bridgeKey: liveEditBridgeKey,
2289
+ }),
2290
+ });
2291
+ if (!response.ok) {
2292
+ throw new Error(`Bridge registration failed (${response.status})`);
2293
+ }
2294
+ // coercion-ok: response.ok already confirmed the registration itself
2295
+ // succeeded above; bridgeInstanceId is supplementary metadata for the
2296
+ // restart-detection heuristic only (see classifyLiveEditHealthProbe),
2297
+ // and the null/missing case below is checked explicitly, not treated
2298
+ // as equivalent to a present value.
2299
+ const payload = (await response.json().catch(() => null)) as {
2300
+ bridgeInstanceId?: string;
2301
+ } | null;
2302
+ if (!isCurrent()) return null;
2303
+ if (payload && typeof payload.bridgeInstanceId === "string") {
2304
+ // Cache the instance id from THIS successful registration so a
2305
+ // later suspected-restart probe (see handleSuspectedBridgeRestart)
2306
+ // can tell a genuinely restarted bridge process apart from the same
2307
+ // process rejecting a stale key. Written only after isCurrent()
2308
+ // passes: an older overlapping request resolving after a newer one
2309
+ // must not overwrite the current attempt's instance id, or the
2310
+ // watchdog misdiagnoses a restart and burns its reload/retry budget.
2311
+ bridgeInstanceIdRef.current = payload.bridgeInstanceId;
2312
+ }
2313
+ bridgeRegistrationRetryAttemptRef.current = 0;
2314
+ if (registrationHandoffKey) {
2315
+ liveEditRegistrationHandoff.set(registrationHandoffKey, Date.now());
2316
+ }
2317
+ // liveEditRestartAttemptRef is intentionally NOT reset here: a
2318
+ // successful registration POST only proves the bridge accepted the
2319
+ // script, not that the live document actually loaded it (that's what
2320
+ // the ready-handshake watchdog below still has to confirm). Resetting
2321
+ // the restart budget on every registration success — rather than only
2322
+ // on a genuine agent-native:editor-chrome-ready — would let a
2323
+ // pathological bridge that keeps minting a new bridgeInstanceId
2324
+ // reregister forever, defeating MAX_LIVE_EDIT_RESTART_ATTEMPTS.
2325
+ setBridgeRegistrationError(null);
2326
+ setBridgeRegistrationFailureKind(null);
2327
+ setBridgeConnectionLostError(null);
2328
+ setConnectingLocalNetworkAccess(false);
2329
+ lateLiveEditReadyRecoveryRef.current = null;
2330
+ setRegisteredLiveEditBridgeKey(liveEditBridgeKey);
2331
+ return true;
2332
+ } catch (error) {
2333
+ if (!isCurrent()) return null;
2334
+ if (registrationHandoffKey) {
2335
+ liveEditRegistrationHandoff.delete(registrationHandoffKey);
2336
+ }
2337
+ console.warn("live-edit bridge registration failed", error);
2338
+ setRegisteredLiveEditBridgeKey(null);
2339
+ setBridgeRegistrationError({
2340
+ bridgeKey: liveEditBridgeKey,
2341
+ message: error instanceof Error ? error.message : String(error),
2342
+ });
2343
+ setConnectingLocalNetworkAccess(false);
2344
+ void classifyBridgeRegistrationFailure().then((kind) => {
2345
+ if (isCurrent()) setBridgeRegistrationFailureKind(kind);
2346
+ });
2347
+ return false;
2348
+ }
2349
+ }, [
2350
+ bridgeUrl,
2351
+ liveEditBridgeKey,
2352
+ liveEditBridgeScript,
2353
+ previewToken,
2354
+ registrationHandoffKey,
2355
+ usesLiveEditInjectedBridge,
2356
+ ]);
2148
2357
  useEffect(() => {
2149
2358
  if (!usesLiveEditInjectedBridge || !bridgeUrl || !previewToken) {
2359
+ // Invalidate any attempt still in flight from before this branch was
2360
+ // entered (previous bridge key/mode) BEFORE clearing state below —
2361
+ // otherwise that stale attempt's isCurrent() check would still pass
2362
+ // when it resolves and could restore registeredLiveEditBridgeKey,
2363
+ // failure state, or handoff data after we've already left this
2364
+ // localhost/bridge configuration.
2365
+ bridgeRegistrationAttemptGenerationRef.current += 1;
2150
2366
  bridgeRegistrationRetryAttemptRef.current = 0;
2151
2367
  liveEditRestartAttemptRef.current = 0;
2152
2368
  liveEditSameInstanceElapsedMsRef.current = 0;
@@ -2157,94 +2373,37 @@ export function DesignCanvas({
2157
2373
  }
2158
2374
  setRegisteredLiveEditBridgeKey(null);
2159
2375
  setBridgeRegistrationError(null);
2376
+ setBridgeRegistrationFailureKind(null);
2377
+ setBridgeConnectionLostError(null);
2160
2378
  setLiveEditSameInstanceStalledError(null);
2161
2379
  lateLiveEditReadyRecoveryRef.current = null;
2162
2380
  return;
2163
2381
  }
2164
- let cancelled = false;
2165
- let retryTimer: number | undefined;
2166
2382
  setRegisteredLiveEditBridgeKey((current) =>
2167
2383
  current === liveEditBridgeKey ? current : null,
2168
2384
  );
2169
- const scheduleRetry = () => {
2170
- if (cancelled) return;
2171
- const delay = getSnapshotRetryDelayMs(
2172
- bridgeRegistrationRetryAttemptRef.current,
2173
- );
2174
- bridgeRegistrationRetryAttemptRef.current += 1;
2175
- retryTimer = window.setTimeout(() => {
2176
- setBridgeRegistrationRetryNonce((nonce) => nonce + 1);
2177
- }, delay);
2178
- };
2179
- void (async () => {
2180
- const endpoint = new URL("/live-edit-bridge", bridgeUrl).toString();
2181
- try {
2182
- const response = await fetch(endpoint, {
2183
- method: "POST",
2184
- headers: {
2185
- "content-type": "application/json",
2186
- "x-design-preview-token": previewToken,
2187
- },
2188
- body: JSON.stringify({
2189
- script: liveEditBridgeScript,
2190
- bridgeKey: liveEditBridgeKey,
2191
- }),
2192
- });
2193
- if (!response.ok) {
2194
- throw new Error(`Bridge registration failed (${response.status})`);
2195
- }
2196
- const payload = (await response.json().catch(() => null)) as {
2197
- bridgeInstanceId?: string;
2198
- } | null;
2199
- if (payload && typeof payload.bridgeInstanceId === "string") {
2200
- // Cache the instance id from THIS successful registration so a
2201
- // later suspected-restart probe (see handleSuspectedBridgeRestart)
2202
- // can tell a genuinely restarted bridge process apart from the same
2203
- // process rejecting a stale key.
2204
- bridgeInstanceIdRef.current = payload.bridgeInstanceId;
2205
- }
2206
- if (cancelled) return;
2207
- bridgeRegistrationRetryAttemptRef.current = 0;
2208
- if (registrationHandoffKey) {
2209
- liveEditRegistrationHandoff.set(registrationHandoffKey, Date.now());
2210
- }
2211
- // liveEditRestartAttemptRef is intentionally NOT reset here: a
2212
- // successful registration POST only proves the bridge accepted the
2213
- // script, not that the live document actually loaded it (that's what
2214
- // the ready-handshake watchdog below still has to confirm). Resetting
2215
- // the restart budget on every registration success — rather than only
2216
- // on a genuine agent-native:editor-chrome-ready — would let a
2217
- // pathological bridge that keeps minting a new bridgeInstanceId
2218
- // reregister forever, defeating MAX_LIVE_EDIT_RESTART_ATTEMPTS.
2219
- setBridgeRegistrationError(null);
2220
- lateLiveEditReadyRecoveryRef.current = null;
2221
- setRegisteredLiveEditBridgeKey(liveEditBridgeKey);
2222
- } catch (error) {
2223
- if (!cancelled) {
2224
- if (registrationHandoffKey) {
2225
- liveEditRegistrationHandoff.delete(registrationHandoffKey);
2226
- }
2227
- console.warn("live-edit bridge registration failed", error);
2228
- setRegisteredLiveEditBridgeKey(null);
2229
- setBridgeRegistrationError({
2230
- bridgeKey: liveEditBridgeKey,
2231
- message: error instanceof Error ? error.message : String(error),
2232
- });
2233
- scheduleRetry();
2234
- }
2235
- }
2236
- })();
2385
+ let cancelled = false;
2386
+ void attemptBridgeRegistration().then((result) => {
2387
+ // result === false is a definite, still-applicable failure; null means
2388
+ // a newer attempt already superseded this one (see
2389
+ // attemptBridgeRegistration's return-type comment) and must not
2390
+ // schedule a redundant retry.
2391
+ if (result === false && !cancelled) scheduleBridgeRegistrationRetry();
2392
+ });
2237
2393
  return () => {
2238
2394
  cancelled = true;
2239
- if (retryTimer) window.clearTimeout(retryTimer);
2395
+ if (bridgeRegistrationRetryTimerRef.current !== undefined) {
2396
+ window.clearTimeout(bridgeRegistrationRetryTimerRef.current);
2397
+ bridgeRegistrationRetryTimerRef.current = undefined;
2398
+ }
2240
2399
  };
2241
2400
  }, [
2401
+ attemptBridgeRegistration,
2242
2402
  bridgeRegistrationRetryNonce,
2243
2403
  bridgeUrl,
2244
2404
  liveEditBridgeKey,
2245
- liveEditBridgeScript,
2246
2405
  previewToken,
2247
- registrationHandoffKey,
2406
+ scheduleBridgeRegistrationRetry,
2248
2407
  usesLiveEditInjectedBridge,
2249
2408
  ]);
2250
2409
 
@@ -2271,12 +2430,18 @@ export function DesignCanvas({
2271
2430
  lateLiveEditReadyRecoveryRef.current = null;
2272
2431
  }, [liveEditBridgeKey]);
2273
2432
 
2274
- // Manual retry (offline-state "Retry" button): reset the backoff so the
2275
- // user-initiated attempt fires immediately, mirroring
2276
- // handleManualSnapshotRetry below. Also clears the non-destructive
2277
- // same-instance-id stalled-error card and its escalation state, since a
2278
- // manual retry (from either error card) should start every counter fresh.
2279
- const handleManualBridgeRegistrationRetry = useCallback(() => {
2433
+ // Chrome only offers its Local Network Access permission dialog for a
2434
+ // fetch made within an active user-gesture window, so this calls the
2435
+ // SAME attemptBridgeRegistration used by the automatic retry effect
2436
+ // directly and synchronously — not through a nonce bump, which would
2437
+ // defer the actual fetch() past the click's gesture window and lose the
2438
+ // ability to trigger Chrome's permission prompt. Also resets the backoff
2439
+ // and same-instance escalation state, mirroring the offline-state "Retry"
2440
+ // buttons elsewhere in this file, so the user-initiated attempt starts
2441
+ // every counter fresh and any already-scheduled auto-retry doesn't fire a
2442
+ // second, redundant attempt shortly after this one.
2443
+ const handleConnectLocalNetworkAccess = useCallback(() => {
2444
+ setConnectingLocalNetworkAccess(true);
2280
2445
  bridgeRegistrationRetryAttemptRef.current = 0;
2281
2446
  liveEditRestartAttemptRef.current = 0;
2282
2447
  liveEditSameInstanceElapsedMsRef.current = 0;
@@ -2286,8 +2451,23 @@ export function DesignCanvas({
2286
2451
  liveEditSameInstanceRearmTimerRef.current = undefined;
2287
2452
  }
2288
2453
  setLiveEditSameInstanceStalledError(null);
2289
- setBridgeRegistrationRetryNonce((nonce) => nonce + 1);
2290
- }, []);
2454
+ if (bridgeRegistrationRetryTimerRef.current !== undefined) {
2455
+ window.clearTimeout(bridgeRegistrationRetryTimerRef.current);
2456
+ bridgeRegistrationRetryTimerRef.current = undefined;
2457
+ }
2458
+ // A failed manual attempt (still-refused permission, dev server still
2459
+ // down) must not silently stop automatic recovery — schedule the same
2460
+ // backoff retry the automatic path uses. null means a newer attempt
2461
+ // (effect-driven or another click) already superseded this one, which
2462
+ // already has its own outcome to handle; only a definite false schedules
2463
+ // here.
2464
+ void attemptBridgeRegistration().then((result) => {
2465
+ if (result === false) scheduleBridgeRegistrationRetry();
2466
+ });
2467
+ }, [attemptBridgeRegistration, scheduleBridgeRegistrationRetry]);
2468
+ const handleDismissLocalNetworkAccessPrompt = useCallback(() => {
2469
+ setLocalNetworkAccessDismissedForKey(liveEditBridgeKey);
2470
+ }, [liveEditBridgeKey]);
2291
2471
 
2292
2472
  // The registered iframe's `src` is a real cross-origin navigation straight
2293
2473
  // to the bridge's authenticated /live-edit URL, so this component can never
@@ -2303,12 +2483,29 @@ export function DesignCanvas({
2303
2483
  if (!bridgeUrl || !previewToken) return;
2304
2484
  if (liveEditRestartInFlightRef.current) return;
2305
2485
  liveEditRestartInFlightRef.current = true;
2486
+ // Captured once per call, not re-read at each checkpoint below: a probe
2487
+ // started against an old screen/key can resolve after a NEW screen has
2488
+ // already registered successfully (bumping this same counter via
2489
+ // attemptBridgeRegistration or the unmount-cleanup effect above) — this
2490
+ // guards every mutating branch below against silently tearing down or
2491
+ // re-registering that newer, unrelated registration.
2492
+ // Deliberately not gated on a one-way "isUnmounted" flag: that shape
2493
+ // never resets, so it would stay stuck true after StrictMode's dev-only
2494
+ // mount→cleanup→mount replay and silently stop every later health probe
2495
+ // from ever resolving (see attemptBridgeRegistration's identical fix
2496
+ // above). The dedicated unmount-cleanup effect there bumps this same
2497
+ // counter on a real
2498
+ // unmount, which isHealthProbeCurrent() already covers correctly.
2499
+ const healthProbeGeneration =
2500
+ bridgeRegistrationAttemptGenerationRef.current;
2501
+ const isHealthProbeCurrent = () =>
2502
+ bridgeRegistrationAttemptGenerationRef.current === healthProbeGeneration;
2306
2503
  try {
2307
2504
  const response = await fetch(healthEndpointUrl(bridgeUrl));
2308
2505
  const payload = (await response.json().catch(() => null)) as {
2309
2506
  bridgeInstanceId?: string;
2310
2507
  } | null;
2311
- if (isUnmountedRef.current) return;
2508
+ if (!isHealthProbeCurrent()) return;
2312
2509
  const responseBridgeInstanceId =
2313
2510
  payload && typeof payload.bridgeInstanceId === "string"
2314
2511
  ? payload.bridgeInstanceId
@@ -2333,7 +2530,7 @@ export function DesignCanvas({
2333
2530
  }
2334
2531
  : null;
2335
2532
  setRegisteredLiveEditBridgeKey(null);
2336
- setBridgeRegistrationError({
2533
+ setBridgeConnectionLostError({
2337
2534
  bridgeKey: liveEditBridgeKey,
2338
2535
  message: t("designCanvas.localBridge.confirmationRetryExhausted"),
2339
2536
  });
@@ -2351,7 +2548,7 @@ export function DesignCanvas({
2351
2548
  // re-triggers the registration effect above.
2352
2549
  bridgeInstanceIdRef.current = responseBridgeInstanceId;
2353
2550
  setRegisteredLiveEditBridgeKey(null);
2354
- setBridgeRegistrationError(null);
2551
+ setBridgeConnectionLostError(null);
2355
2552
  setLiveEditSameInstanceStalledError(null);
2356
2553
  // A genuine restart makes any same-instance-id wait we'd accumulated
2357
2554
  // against the OLD process meaningless — reset the escalation clock so
@@ -2396,7 +2593,7 @@ export function DesignCanvas({
2396
2593
  }
2397
2594
  liveEditSameInstanceRearmTimerRef.current = window.setTimeout(() => {
2398
2595
  liveEditSameInstanceRearmTimerRef.current = undefined;
2399
- if (isUnmountedRef.current || bridgeReadyRef.current) return;
2596
+ if (bridgeReadyRef.current || !isHealthProbeCurrent()) return;
2400
2597
  void handleSuspectedBridgeRestart();
2401
2598
  }, nextDelay);
2402
2599
  return;
@@ -2417,12 +2614,12 @@ export function DesignCanvas({
2417
2614
  }
2418
2615
  : null;
2419
2616
  setRegisteredLiveEditBridgeKey(null);
2420
- setBridgeRegistrationError({
2617
+ setBridgeConnectionLostError({
2421
2618
  bridgeKey: liveEditBridgeKey,
2422
2619
  message: t("designCanvas.localBridge.connectionNotConfirmed"),
2423
2620
  });
2424
2621
  } catch (error) {
2425
- if (isUnmountedRef.current) return;
2622
+ if (!isHealthProbeCurrent()) return;
2426
2623
  // /health itself is unreachable (network error / thrown before a
2427
2624
  // response) — the dev server process is actually down, not just slow.
2428
2625
  // This destructive path (tear down + surface the error) is justified.
@@ -2438,7 +2635,7 @@ export function DesignCanvas({
2438
2635
  }
2439
2636
  : null;
2440
2637
  setRegisteredLiveEditBridgeKey(null);
2441
- setBridgeRegistrationError({
2638
+ setBridgeConnectionLostError({
2442
2639
  bridgeKey: liveEditBridgeKey,
2443
2640
  message: error instanceof Error ? error.message : String(error),
2444
2641
  });
@@ -2449,8 +2646,8 @@ export function DesignCanvas({
2449
2646
 
2450
2647
  // Manual retry for the NON-destructive same-instance-id stalled card only
2451
2648
  // (see liveEditSameInstanceStalledError below): unlike
2452
- // handleManualBridgeRegistrationRetry, registeredLiveEditBridgeKey was
2453
- // never nulled here, so bumping bridgeRegistrationRetryNonce would not
2649
+ // handleConnectLocalNetworkAccess, registeredLiveEditBridgeKey was never
2650
+ // nulled here, so calling attemptBridgeRegistration again would not
2454
2651
  // reschedule a fresh watchdog probe (liveEditBridgeRegistered never flips
2455
2652
  // false→true to rearm that effect). Reset the backoff and probe /health
2456
2653
  // again directly instead.
@@ -2918,9 +3115,14 @@ export function DesignCanvas({
2918
3115
  // the same false-success shape as rendering the snapshot outright: when the
2919
3116
  // swap stalls, the canvas keeps showing a screen that looks correct and
2920
3117
  // responds to nothing. A brief flash is the honest signal.
3118
+ // A raw fallback document (see usingRawFallbackPreview above) never gets an
3119
+ // injected editor-chrome bridge, so it can never post the ready handshake
3120
+ // this waits for — without excluding it here, the fallback iframe would
3121
+ // stay marked "pending" (and thus blocked by the overlay below) forever.
2921
3122
  const liveEditDocumentPending =
2922
3123
  usesLiveEditEditorBridge &&
2923
3124
  Boolean(externalPreviewUrl) &&
3125
+ !usingRawFallbackPreview &&
2924
3126
  readyIframeDocumentIdentity !== iframeDocumentIdentity;
2925
3127
  // A proxied container paints its own app immediately, so without this the
2926
3128
  // canvas looks ready while hover, selection and layers are still dead.
@@ -3073,7 +3275,7 @@ export function DesignCanvas({
3073
3275
  Date.now(),
3074
3276
  );
3075
3277
  }
3076
- setBridgeRegistrationError((current) =>
3278
+ setBridgeConnectionLostError((current) =>
3077
3279
  current?.bridgeKey === lateReadyRecovery.bridgeKey ? null : current,
3078
3280
  );
3079
3281
  setRegisteredLiveEditBridgeKey(lateReadyRecovery.bridgeKey);
@@ -5440,20 +5642,32 @@ export function DesignCanvas({
5440
5642
  </div>
5441
5643
  </div>
5442
5644
  ) : null}
5645
+ {bridgeRegistrationFailedForCurrentKey &&
5646
+ localNetworkAccessDismissedForKey !== liveEditBridgeKey ? (
5647
+ // Deliberately NOT inside the blocking overlay below: usingRawFallback
5648
+ // Preview means the iframe right underneath is the real running dev
5649
+ // server, so this stays a small, dismissible corner card rather than
5650
+ // hiding working content behind an indefinite "preparing" screen.
5651
+ <LocalNetworkAccessPrompt
5652
+ kind={bridgeRegistrationFailureKind ?? "maybePermissionBlocked"}
5653
+ connecting={connectingLocalNetworkAccess}
5654
+ onConnect={handleConnectLocalNetworkAccess}
5655
+ onDismiss={handleDismissLocalNetworkAccessPrompt}
5656
+ />
5657
+ ) : null}
5443
5658
  {waitingForEditableExternalSnapshot ||
5444
- waitingForLiveEditBridge ||
5659
+ (waitingForLiveEditBridge && !bridgeRegistrationFailedForCurrentKey) ||
5445
5660
  sameOriginBridgePending ||
5446
5661
  liveEditDocumentPending ? (
5447
5662
  <div className="pointer-events-auto absolute inset-0 z-10 flex items-center justify-center bg-background/85 px-4 text-center text-sm text-muted-foreground">
5448
- {waitingForLiveEditBridge &&
5449
- bridgeRegistrationError?.bridgeKey === liveEditBridgeKey ? (
5450
- // Mirrors the snapshot-fetch offline card below: a stuck
5451
- // registration used to look identical to ordinary "still
5452
- // registering" with no explanation and no way to recover short
5453
- // of a full page reload. Only shown for the CURRENT
5454
- // liveEditBridgeKey — a stale error from a previous script
5455
- // revision must not linger after content/mode changes move on
5456
- // to registering a new one.
5663
+ {bridgeConnectionLostError?.bridgeKey === liveEditBridgeKey ? (
5664
+ // handleSuspectedBridgeRestart's destructive terminal state (see
5665
+ // bridgeConnectionLostError's declaration comment): the
5666
+ // registration itself succeeded, so unlike the floating
5667
+ // LocalNetworkAccessPrompt card above there's no raw dev-server
5668
+ // fallback to show underneath — the live document is what
5669
+ // stopped responding, not the fetch. Keeps its original
5670
+ // full-cover card and copy, unchanged from before this PR.
5457
5671
  <div className="pointer-events-auto flex max-w-[28rem] flex-col items-center gap-2 rounded-md border bg-card px-4 py-3 shadow-sm">
5458
5672
  <div className="flex items-center gap-1.5 font-medium text-foreground">
5459
5673
  <IconPlugConnectedX className="size-4 shrink-0 text-destructive" />
@@ -5467,13 +5681,13 @@ export function DesignCanvas({
5467
5681
  }
5468
5682
  </div>
5469
5683
  <div className="w-full truncate rounded bg-muted px-2 py-1 font-mono text-[11px] text-muted-foreground">
5470
- {bridgeRegistrationError.message}
5684
+ {bridgeConnectionLostError.message}
5471
5685
  </div>
5472
5686
  <Button
5473
5687
  type="button"
5474
5688
  variant="outline"
5475
5689
  size="sm"
5476
- onClick={handleManualBridgeRegistrationRetry}
5690
+ onClick={handleConnectLocalNetworkAccess}
5477
5691
  >
5478
5692
  <IconRefresh className="size-3.5" />
5479
5693
  {"Retry" /* i18n-ignore local dev bridge retry button */}