fix: resolve false 'static page' detection and missing route discovery on CLI-proxied apps

Three related fixes for the canvas live-render panel:

1. captureExistingTree() in fiber-hook — retroactively walks the already-
   mounted React fiber tree via __reactFiber$ DOM annotations. Called
   immediately on READY (handles post-hydration race) and again at 2 s
   (handles deferred hydration / Suspense). Prevents the 'Static HTML page'
   false positive on React apps that mounted before the hook script ran.

2. Static-page timer bumped from 4 s → 8 s in LiveArtboard to give the
   2-second captureExistingTree safety-net enough headroom before the
   no-React verdict fires.

3. Route discovery same-origin fix applied to both renderer/fiber-hook.ts
   and live-sdk/hook.ts — root-relative hrefs (starting with '/') are now
   accepted regardless of origin so CLI-proxied pages (where links still
   point to the original domain) expose their navigation routes correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SinachPat
2026-05-07 08:36:20 +01:00
co-authored by Claude Sonnet 4.6
parent 3f1fd802bf
commit 8bbee987ff
3 changed files with 84 additions and 6 deletions
@@ -124,11 +124,13 @@ export function LiveArtboard({
pendingStylesFetchRef.current = selectedComponentId; pendingStylesFetchRef.current = selectedComponentId;
} }
onReady?.(); onReady?.();
// Start a 4-second timer: if React never commits, this is a static page. // Start an 8-second timer: if React never commits (including the
// 2-second retroactive captureExistingTree safety net), this is
// genuinely a static page.
if (staticTimerRef.current) clearTimeout(staticTimerRef.current); if (staticTimerRef.current) clearTimeout(staticTimerRef.current);
staticTimerRef.current = setTimeout(() => { staticTimerRef.current = setTimeout(() => {
if (!hasReactRef.current) onStaticPageDetected?.(); if (!hasReactRef.current) onStaticPageDetected?.();
}, 4000); }, 8000);
break; break;
case 'FIBER_TREE_UPDATE': case 'FIBER_TREE_UPDATE':
// Mark that this iframe contains a live React app. // Mark that this iframe contains a live React app.
+10 -1
View File
@@ -442,9 +442,18 @@ function installFiberHook(): void {
// Current route first // Current route first
addRoute(window.location.pathname, document.title || undefined); addRoute(window.location.pathname, document.title || undefined);
// Scan real <a> elements // Scan real <a> elements — accept root-relative paths regardless of origin
// so that CLI-proxied pages (where links still point to the original domain)
// are handled correctly alongside direct same-origin connections.
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => { document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
try { try {
const rawHref = (a.getAttribute('href') ?? '').trim();
// Root-relative paths are always valid routes.
if (rawHref.startsWith('/')) {
addRoute(rawHref, a.textContent ?? undefined);
return;
}
// Absolute URLs — only add if same-origin (direct connection).
const url = new URL(a.href, window.location.href); const url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return; if (url.origin !== window.location.origin) return;
addRoute(url.pathname, a.textContent ?? undefined); addRoute(url.pathname, a.textContent ?? undefined);
+70 -3
View File
@@ -868,14 +868,25 @@ export function buildProxyFiberHookScript(): string {
} catch (e) { /* Next.js not present */ } } catch (e) { /* Next.js not present */ }
})(); })();
// ③ <a href> same-origin links from the rendered DOM // ③ <a href> links — relative paths AND same-origin absolute URLs.
// When running through the CLI proxy, the page's links still point to the
// original domain (e.g. intraining.com), not the proxy (localhost:4170).
// A strict same-origin check would reject all of them. Instead, accept
// any href that is already a root-relative path ("/courses"), plus
// same-origin absolute URLs for direct (non-proxied) connections.
var anchors = document.querySelectorAll('a[href]'); var anchors = document.querySelectorAll('a[href]');
for (var i = 0; i < anchors.length; i++) { for (var i = 0; i < anchors.length; i++) {
try { try {
var rawHref = (anchors[i].getAttribute('href') || '').trim();
// Root-relative paths — always valid routes regardless of origin
if (rawHref.charAt(0) === '/') {
addRoute(rawHref, (anchors[i].textContent || '').trim() || undefined);
continue;
}
// Absolute URLs — only add if same-origin (direct connection)
var url = new URL(anchors[i].href, window.location.href); var url = new URL(anchors[i].href, window.location.href);
if (url.origin !== window.location.origin) continue; if (url.origin !== window.location.origin) continue;
// Skip hash-only links if (!url.pathname || (url.hash && !url.pathname)) continue;
if (!url.pathname || url.hash && !url.pathname) continue;
addRoute(url.pathname, (anchors[i].textContent || '').trim() || undefined); addRoute(url.pathname, (anchors[i].textContent || '').trim() || undefined);
} catch (e) { /* skip malformed hrefs */ } } catch (e) { /* skip malformed hrefs */ }
} }
@@ -899,12 +910,68 @@ export function buildProxyFiberHookScript(): string {
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes: routes }); if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes: routes });
} }
// ── Retroactive fiber capture ─────────────────────────────────────────────
// onCommitFiberRoot only fires on FUTURE commits. If React already completed
// its first render (hydration) before our hook script was evaluated, we miss
// the initial tree entirely and the 4-second static-page timer fires falsely.
//
// Fix: scan common React root containers for __reactFiber$ DOM annotations
// that React writes onto every host element. Walk the found fiber up to the
// HostRoot (fiber.return chain) and serialize it exactly as onCommitFiberRoot
// does. Two attempts cover the two race modes:
// Attempt 1 (immediate) hook ran after hydration; DOM is populated now.
// Attempt 2 (2 s delay) hook ran before hydration or Suspense deferred.
function captureExistingTree() {
// Find any DOM element that React has annotated with a fiber reference.
var candidates = [
document.getElementById('__next'),
document.getElementById('root'),
document.getElementById('app'),
document.body,
];
var fiber = null;
for (var ci = 0; ci < candidates.length; ci++) {
var el = candidates[ci];
if (!el) continue;
var keys = Object.keys(el);
for (var ki = 0; ki < keys.length; ki++) {
if (keys[ki].indexOf('__reactFiber$') === 0) {
fiber = el[keys[ki]];
break;
}
}
if (fiber) break;
}
if (!fiber) return; // React not yet mounted on any known container.
// Walk up to the HostRoot (the sentinel fiber React builds the tree from).
var f = fiber;
while (f.return) f = f.return;
// Rebuild maps and serialize — identical to what onCommitFiberRoot does.
nodeMap = {};
fiberMap = new WeakMap();
var tree = serializeFiber(f, '');
if (!tree) return; // Nothing serializable yet.
reapplyOverrides();
post({ type: 'FIBER_TREE_UPDATE', root: tree });
if (selectedNodeId) updateHighlight();
}
// ── Ready signal (includes root font size for rem→px normalisation) ──────── // ── Ready signal (includes root font size for rem→px normalisation) ────────
var rootFontSizePx = parseFloat( var rootFontSizePx = parseFloat(
window.getComputedStyle(document.documentElement).getPropertyValue('font-size') || '16' window.getComputedStyle(document.documentElement).getPropertyValue('font-size') || '16'
) || 16; ) || 16;
post({ type: 'READY', rootFontSizePx: rootFontSizePx }); post({ type: 'READY', rootFontSizePx: rootFontSizePx });
// Attempt 1: capture already-mounted React tree immediately (handles the
// common case where hydration completed before the hook script ran).
captureExistingTree();
setTimeout(discoverRoutes, 800); setTimeout(discoverRoutes, 800);
// Attempt 2: safety-net capture 2 s later for deferred hydration / Suspense.
setTimeout(captureExistingTree, 2000);
// Re-discover on SPA navigation (Next.js App Router fires popstate on push) // Re-discover on SPA navigation (Next.js App Router fires popstate on push)
window.addEventListener('popstate', function() { setTimeout(discoverRoutes, 100); }); window.addEventListener('popstate', function() { setTimeout(discoverRoutes, 100); });
})();`; })();`;