This commit is contained in:
kami 2026-05-31 10:19:31 +00:00 committed by GitHub
commit fafa413ed6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1565 additions and 119 deletions

View file

@ -659,8 +659,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
}); });
} }
}, },
get contaminated() { get contaminated() {
return guard.contaminated; return guard.contaminated;
}, },
}; };
} }
@ -748,10 +748,10 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
} }
if (event === 'content_block_delta' && typeof data.delta?.text === 'string') { if (event === 'content_block_delta' && typeof data.delta?.text === 'string') {
guard.sendDelta(data.delta.text); guard.sendDelta(data.delta.text);
if (guard.contaminated) { if (guard.contaminated) {
sse.send('end', {}); sse.send('end', {});
ended = true; ended = true;
return true; return true;
} }
} }
if (event === 'message_stop') { if (event === 'message_stop') {
@ -862,13 +862,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
return true; return true;
} }
const delta = extractOpenAIText(data); const delta = extractOpenAIText(data);
if (delta) { if (delta) {
guard.sendDelta(delta); guard.sendDelta(delta);
if (guard.contaminated) { if (guard.contaminated) {
sse.send('end', {}); sse.send('end', {});
ended = true; ended = true;
return true; return true;
} }
} }
return false; return false;
}); });
@ -1017,12 +1017,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
return true; return true;
} }
const delta = extractOpenAIText(data); const delta = extractOpenAIText(data);
if (delta) { guard.sendDelta(delta); if (delta) { guard.sendDelta(delta);
if (guard.contaminated) { if (guard.contaminated) {
sse.send('end', {}); sse.send('end', {});
ended = true; ended = true;
return true; return true;
} }
} }
return false; return false;
}); });
@ -1122,12 +1122,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
return true; return true;
} }
const delta = extractGeminiText(data); const delta = extractGeminiText(data);
if (delta) { guard.sendDelta(delta); if (delta) { guard.sendDelta(delta);
if (guard.contaminated) { if (guard.contaminated) {
sse.send('end', {}); sse.send('end', {});
ended = true; ended = true;
return true; return true;
} }
} }
const blockMessage = extractGeminiBlockMessage(data); const blockMessage = extractGeminiBlockMessage(data);
if (blockMessage) { if (blockMessage) {
@ -1215,13 +1215,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
return true; return true;
} }
const content = data.message?.content; const content = data.message?.content;
if (typeof content === 'string' && content) { if (typeof content === 'string' && content) {
guard.sendDelta(content); guard.sendDelta(content);
if (guard.contaminated) { if (guard.contaminated) {
sse.send('end', {}); sse.send('end', {});
ended = true; ended = true;
return true; return true;
} }
} }
return false; return false;
}); });
@ -1415,9 +1415,9 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
// we want the user to see whatever the model decided to say. // we want the user to see whatever the model decided to say.
if (typeof delta.content === 'string' && delta.content) { if (typeof delta.content === 'string' && delta.content) {
guard.sendDelta(delta.content); guard.sendDelta(delta.content);
if (guard.contaminated) { if (guard.contaminated) {
sse.send('end', {}); sse.send('end', {});
return true; return true;
} }
} }

View file

@ -32,6 +32,156 @@ import { auditDesignSystemPackage } from './tools-connectors-cli.js';
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {} export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
function shouldInjectPreviewNavigationBridge(queryValue: unknown): boolean {
if (Array.isArray(queryValue)) return queryValue.some(shouldInjectPreviewNavigationBridge);
if (queryValue === true) return true;
if (typeof queryValue !== 'string') return false;
return ['1', 'true', 'yes', 'on'].includes(queryValue.trim().toLowerCase());
}
function injectPreviewNavigationBridge(html: string): string {
const script = `<script data-od-preview-navigation-bridge>(function(){
function state(requestId){
var message = {
type: 'od:preview-navigation',
href: location.href,
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: history.state
};
if (typeof requestId === 'string') message.requestId = requestId;
return message;
}
function post(requestId){
try { window.parent.postMessage(state(requestId), '*'); } catch (_) {}
}
function restore(data){
var prevHash = location.hash;
try {
var hash = typeof data.hash === 'string' ? data.hash : location.hash;
var pathname = typeof data.pathname === 'string' && data.pathname.charAt(0) === '/' ? data.pathname : location.pathname;
var search = typeof data.search === 'string' ? data.search : location.search;
history.replaceState('state' in data ? data.state : history.state, '', pathname + search + hash);
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (__) {}
if (location.hash !== prevHash) {
try {
var hashEv = typeof HashChangeEvent === 'function'
? new HashChangeEvent('hashchange', { oldURL: '', newURL: location.href })
: new Event('hashchange');
window.dispatchEvent(hashEv);
} catch (__) {}
}
} catch (_) {
if (typeof data.hash === 'string' && location.hash !== data.hash) {
try { location.hash = data.hash; } catch (__) {}
}
if ('state' in data) {
try { history.replaceState(data.state, '', location.href); } catch (__) {}
}
}
post();
}
function patch(name){
var original = history[name];
if (typeof original !== 'function') return;
history[name] = function(){
var result = original.apply(this, arguments);
post();
return result;
};
}
patch('pushState');
patch('replaceState');
window.addEventListener('hashchange', post);
window.addEventListener('popstate', post);
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data) return;
if (data.type === 'od:preview-navigation-request') {
post(typeof data.requestId === 'string' ? data.requestId : undefined);
return;
}
if (data.type === 'od:preview-navigation-restore') {
restore(data);
return;
}
});
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', post, { once: true });
else setTimeout(post, 0);
})();</script>`;
if (/<head[^>]*>/i.test(html)) {
return html.replace(/<head[^>]*>/i, (m) => `${m}${script}`);
}
if (/<body[^>]*>/i.test(html)) {
return html.replace(/<body[^>]*>/i, (m) => `${m}${script}`);
}
return script + html;
}
function buildSrcdocTransportShell(): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script data-od-srcdoc-transport-shell>(function(){
function safePath(value){
return typeof value === 'string' && value.charAt(0) === '/' ? value : location.pathname;
}
function safeSearch(value){
if (typeof value !== 'string' || value.length === 0) return '';
return value.charAt(0) === '?' ? value : '?' + value;
}
function safeHash(value){
if (typeof value !== 'string' || value.length === 0) return '';
return value.charAt(0) === '#' ? value : '#' + value;
}
function restoreNavigation(navigation){
if (!navigation || typeof navigation !== 'object') return;
var prevHash = location.hash;
var nextUrl = safePath(navigation.pathname) + safeSearch(navigation.search) + safeHash(navigation.hash);
try {
history.replaceState('state' in navigation ? navigation.state : history.state, '', nextUrl);
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (__) {}
if (location.hash !== prevHash) {
try {
var hashEv = typeof HashChangeEvent === 'function'
? new HashChangeEvent('hashchange', { oldURL: '', newURL: location.href })
: new Event('hashchange');
window.dispatchEvent(hashEv);
} catch (__) {}
}
}
catch (_) {
var hash = safeHash(navigation.hash);
if (hash && location.hash !== hash) {
try { location.hash = hash; } catch (__) {}
}
if ('state' in navigation) {
try { history.replaceState(navigation.state, '', location.href); } catch (__) {}
}
}
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || data.type !== 'od:srcdoc-transport-activate' || typeof data.html !== 'string') return;
restoreNavigation(data.navigation);
document.open();
document.write(data.html);
document.close();
});
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'od:srcdoc-transport-ready' }, '*');
}
} catch (_) {}
})();</script>
</head>
<body></body>
</html>`;
}
function projectDetailResolvedDir( function projectDetailResolvedDir(
projectsRoot: string, projectsRoot: string,
project: any, project: any,
@ -1345,13 +1495,27 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
} }
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata); const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
if ( const isHtmlFile = /^text\/html(?:;|$)/i.test(file.mime);
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) && if (isHtmlFile && shouldInjectPreviewNavigationBridge(req.query.odSrcdocTransport)) {
/^text\/html(?:;|$)/i.test(file.mime) res.type(file.mime).send(buildSrcdocTransportShell());
) {
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
return; return;
} }
if (isHtmlFile) {
let html = file.buffer.toString('utf8');
let instrumented = false;
if (shouldInjectPreviewNavigationBridge(req.query.odPreviewNav)) {
html = injectPreviewNavigationBridge(html);
instrumented = true;
}
if (wantsUrlPreviewScrollBridge(req.query.odPreviewBridge)) {
html = injectUrlPreviewScrollBridge(html);
instrumented = true;
}
if (instrumented) {
res.type(file.mime).send(html);
return;
}
}
res.type(file.mime).send(file.buffer); res.type(file.mime).send(file.buffer);
} catch (err: any) { } catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400; const status = err && err.code === 'ENOENT' ? 404 : 400;

View file

@ -231,6 +231,34 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(text).toBe('<html/>'); expect(text).toBe('<html/>');
}); });
it('injects the preview navigation reporter only when requested for HTML previews', async () => {
const raw = await fetch(rawUrl('page.html'));
expect(await raw.text()).toBe('<html/>');
const instrumented = await fetch(`${rawUrl('page.html')}?odPreviewNav=1`);
expect(instrumented.status).toBe(200);
const text = await instrumented.text();
expect(text).toContain('data-od-preview-navigation-bridge');
expect(text).toContain("type: 'od:preview-navigation'");
expect(text).toContain("data.type === 'od:preview-navigation-request'");
expect(text).toContain('message.requestId = requestId');
expect(text).toContain("data.type === 'od:preview-navigation-restore'");
expect(text).toContain("window.dispatchEvent(new PopStateEvent('popstate'");
expect(text).toContain('new HashChangeEvent');
});
it('serves a same-origin srcdoc transport shell for bridge previews', async () => {
const res = await fetch(`${rawUrl('page.html')}?odSrcdocTransport=1`);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain('data-od-srcdoc-transport-shell');
expect(text).toContain("data.type !== 'od:srcdoc-transport-activate'");
expect(text).toContain("type: 'od:srcdoc-transport-ready'");
expect(text).toContain('history.replaceState');
expect(text).not.toContain('<html/>');
});
it('injects the URL preview scroll bridge only when requested', async () => { it('injects the URL preview scroll bridge only when requested', async () => {
const plain = await fetch(rawUrl('page.html')); const plain = await fetch(rawUrl('page.html'));
expect(await plain.text()).toBe('<html/>'); expect(await plain.text()).toBe('<html/>');
@ -257,6 +285,15 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(html.match(/data-od-url-scroll-bridge/g)?.length).toBe(1); expect(html.match(/data-od-url-scroll-bridge/g)?.length).toBe(1);
}); });
it('can inject preview navigation and scroll bridges together', async () => {
const res = await fetch(`${rawUrl('body.html')}?odPreviewNav=1&odPreviewBridge=scroll`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('data-od-preview-navigation-bridge');
expect(html).toContain('data-od-url-scroll-bridge');
});
it('returns 404 for a missing file', async () => { it('returns 404 for a missing file', async () => {
const res = await fetch(rawUrl('missing.mp4')); const res = await fetch(rawUrl('missing.mp4'));
expect(res.status).toBe(404); expect(res.status).toBe(404);

View file

@ -70,7 +70,7 @@ import {
} from '../runtime/exports'; } from '../runtime/exports';
import { buildReactComponentSrcdoc } from '../runtime/react-component'; import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs'; import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
import { buildLazySrcdocTransport, buildSrcdoc, canActivateSrcDocTransport } from '../runtime/srcdoc'; import { buildSrcdoc, canActivateSrcDocTransport, type SrcdocPreviewNavigation } from '../runtime/srcdoc';
import { import {
hasUrlModeBridge, hasUrlModeBridge,
htmlNeedsFocusGuard, htmlNeedsFocusGuard,
@ -142,6 +142,14 @@ export type ManualEditPendingStyleSave = {
version: number; version: number;
}; };
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile'; type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
type PreviewNavigationState = SrcdocPreviewNavigation & {
capturedAt: number;
};
type PreviewNavigationCaptureRequest = {
id: string;
target: 'url' | 'srcdoc';
};
type PreviewNavigationTarget = 'active' | 'url' | 'srcdoc';
type PreviewCanvasSize = { width: number; height: number }; type PreviewCanvasSize = { width: number; height: number };
type CommentPreviewCanvasOptions = { type CommentPreviewCanvasOptions = {
boardMode: boolean; boardMode: boolean;
@ -4271,6 +4279,10 @@ function HtmlViewer({
canvasTop: 0, canvasTop: 0,
}); });
const previewScrollRequestAtRef = useRef(0); const previewScrollRequestAtRef = useRef(0);
const previewNavigationRef = useRef<PreviewNavigationState | null>(null);
const previewNavigationRestoreRef = useRef<PreviewNavigationState | null>(null);
const previewNavigationRequestSeqRef = useRef(0);
const previewNavigationCaptureRequestRef = useRef<PreviewNavigationCaptureRequest | null>(null);
const dcViewportRef = useRef({ const dcViewportRef = useRef({
x: 0, x: 0,
y: 0, y: 0,
@ -4405,6 +4417,63 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
() => (typeof window === 'undefined' ? false : parseForceInline(window.location.search)), () => (typeof window === 'undefined' ? false : parseForceInline(window.location.search)),
[], [],
); );
const requestPreviewNavigationState = useCallback(() => {
const frame = iframeRef.current;
const source = frame?.contentWindow;
const target =
frame === urlPreviewIframeRef.current
? 'url'
: frame === srcDocPreviewIframeRef.current
? 'srcdoc'
: null;
if (!source) {
previewNavigationCaptureRequestRef.current = null;
return;
}
if (!target) {
previewNavigationCaptureRequestRef.current = null;
return;
}
previewNavigationRequestSeqRef.current += 1;
const requestId = `nav-${previewNavigationRequestSeqRef.current}`;
previewNavigationCaptureRequestRef.current = { id: requestId, target };
try {
source.postMessage({ type: 'od:preview-navigation-request', requestId }, '*');
} catch {
if (previewNavigationCaptureRequestRef.current?.id === requestId) {
previewNavigationCaptureRequestRef.current = null;
}
}
}, []);
const capturePreviewNavigationState = useCallback(() => {
requestPreviewNavigationState();
previewNavigationRestoreRef.current = previewNavigationRef.current;
}, [requestPreviewNavigationState]);
const restorePreviewNavigationState = useCallback((navigation: PreviewNavigationState | null) => {
if (!navigation) return;
const post = (target: PreviewNavigationTarget = 'active') => {
if (previewNavigationRestoreRef.current !== navigation) return;
const frame =
target === 'url'
? urlPreviewIframeRef.current
: target === 'srcdoc'
? srcDocPreviewIframeRef.current
: iframeRef.current;
if (!frame?.contentWindow) return;
if (target === 'active') {
const active = frame.getAttribute('data-od-active') === 'true' || iframeRef.current === frame;
if (!active) return;
}
frame.contentWindow.postMessage({
type: 'od:preview-navigation-restore',
...navigation,
}, '*');
};
post();
window.setTimeout(post, 80);
window.setTimeout(post, 260);
return post;
}, []);
const [activeCommentTarget, setActiveCommentTarget] = useState<PreviewCommentSnapshot | null>(null); const [activeCommentTarget, setActiveCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
const [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null); const [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
const [hoveredPodMemberId, setHoveredPodMemberId] = useState<string | null>(null); const [hoveredPodMemberId, setHoveredPodMemberId] = useState<string | null>(null);
@ -4721,7 +4790,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
needsFocusGuard, needsFocusGuard,
}); });
const basePreviewSrcUrl = useMemo( const basePreviewSrcUrl = useMemo(
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll`, () => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewNav=1&odPreviewBridge=scroll`,
[projectId, file.name, file.mtime, reloadKey],
);
const srcDocTransportSrcUrl = useMemo(
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odSrcdocTransport=1`,
[projectId, file.name, file.mtime, reloadKey], [projectId, file.name, file.mtime, reloadKey],
); );
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl); const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
@ -4733,10 +4806,33 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
: basePreviewSrcUrl; : basePreviewSrcUrl;
useEffect(() => { useEffect(() => {
setPreviewSrcUrl(basePreviewSrcUrl); setPreviewSrcUrl(basePreviewSrcUrl);
previewNavigationRef.current = null;
previewNavigationRestoreRef.current = null;
previewNavigationCaptureRequestRef.current = null;
}, [basePreviewSrcUrl]); }, [basePreviewSrcUrl]);
const useUrlLoadPreviewCurrentRef = useRef(useUrlLoadPreview);
useUrlLoadPreviewCurrentRef.current = useUrlLoadPreview;
const alignActivePreviewIframeRef = useCallback(() => {
iframeRef.current = useUrlLoadPreviewCurrentRef.current ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current;
}, []);
const setUrlPreviewIframeRef = useCallback((frame: HTMLIFrameElement | null) => {
urlPreviewIframeRef.current = frame;
alignActivePreviewIframeRef();
}, [alignActivePreviewIframeRef]);
const setSrcDocPreviewIframeRef = useCallback((frame: HTMLIFrameElement | null) => {
srcDocPreviewIframeRef.current = frame;
alignActivePreviewIframeRef();
}, [alignActivePreviewIframeRef]);
useEffect(() => { useEffect(() => {
iframeRef.current = useUrlLoadPreview ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current; alignActivePreviewIframeRef();
}, [useUrlLoadPreview]); }, [alignActivePreviewIframeRef, useUrlLoadPreview]);
useEffect(() => {
const navigation = previewNavigationRef.current;
if (!navigation) return;
previewNavigationRestoreRef.current = navigation;
const post = restorePreviewNavigationState(navigation);
post?.(useUrlLoadPreview ? 'url' : 'srcdoc');
}, [restorePreviewNavigationState, useUrlLoadPreview]);
useEffect(() => { useEffect(() => {
if (filesRefreshKey === 0) return; if (filesRefreshKey === 0) return;
@ -4769,24 +4865,38 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
deck: effectiveDeck, deck: effectiveDeck,
baseHref: projectRawUrl(projectId, baseDirFor(file.name)), baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0, initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
initialNavigation: previewNavigationRestoreRef.current,
selectionBridge: true, selectionBridge: true,
editBridge: manualEditMode, editBridge: manualEditMode,
paletteBridge: false, paletteBridge: false,
previewFocusGuard: true, previewFocusGuard: true,
}) : ''), }) : ''),
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, manualEditMode], [
previewSource,
effectiveDeck,
projectId,
file.name,
previewStateKey,
previewNavigationRestoreRef.current?.capturedAt,
manualEditMode,
],
); );
const lazySrcDocTransport = useMemo(() => buildLazySrcdocTransport(), []);
const [srcDocTransportResetKey, setSrcDocTransportResetKey] = useState(0); const [srcDocTransportResetKey, setSrcDocTransportResetKey] = useState(0);
const [srcDocShellReady, setSrcDocShellReady] = useState(false); const srcDocShellInstanceKey = `${srcDocTransportResetKey}:${srcDocTransportSrcUrl}`;
const [srcDocShellReadyKey, setSrcDocShellReadyKey] = useState<string | null>(null);
const srcDocShellReady = srcDocShellReadyKey === srcDocShellInstanceKey;
const wasUrlLoadPreviewRef = useRef(useUrlLoadPreview); const wasUrlLoadPreviewRef = useRef(useUrlLoadPreview);
const [hasLazySrcDocTransport, setHasLazySrcDocTransport] = useState(useUrlLoadPreview);
const urlPreviewKeepAliveKey = previewIframeKeepAliveKey(projectId, file.name); const urlPreviewKeepAliveKey = previewIframeKeepAliveKey(projectId, file.name);
// Reset the shell-ready latch whenever the srcDoc iframe re-mounts. The
// next shell will post `od:srcdoc-transport-ready` (or fire onLoad) and
// flip this back to true. See #2253.
useEffect(() => { useEffect(() => {
setSrcDocShellReady(false); if (useUrlLoadPreview) setHasLazySrcDocTransport(true);
}, [srcDocTransportResetKey]); }, [useUrlLoadPreview]);
useEffect(() => {
activatedSrcDocTransportHtmlRef.current = null;
}, [srcDocTransportSrcUrl]);
// Key shell readiness to both the daemon shell URL and forced remount key.
// When either changes, srcDocShellReady falls false until the current shell
// posts `od:srcdoc-transport-ready` or fires onLoad. See #2253.
// Listen for the shell's ready handshake. Gating activation on this is // Listen for the shell's ready handshake. Gating activation on this is
// what fixes the #2253 race: opening Tweaks right after a key-driven // what fixes the #2253 race: opening Tweaks right after a key-driven
// re-mount used to post `activate` before the shell's listener was // re-mount used to post `activate` before the shell's listener was
@ -4797,19 +4907,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
if (ev.source !== srcDocPreviewIframeRef.current?.contentWindow) return; if (ev.source !== srcDocPreviewIframeRef.current?.contentWindow) return;
const data = ev.data as { type?: string } | null; const data = ev.data as { type?: string } | null;
if (data?.type !== 'od:srcdoc-transport-ready') return; if (data?.type !== 'od:srcdoc-transport-ready') return;
setSrcDocShellReady(true); setSrcDocShellReadyKey(srcDocShellInstanceKey);
} }
window.addEventListener('message', onMessage); window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage);
}, []); }, [srcDocShellInstanceKey]);
// Lazy transport preloads an empty shell only while URL-load is the active const useLazySrcDocTransport = useUrlLoadPreview || hasLazySrcDocTransport;
// transport. Once srcdoc becomes active (sandbox shim, Draw, Screenshot,
// Tweaks, etc.), mount the real artifact HTML directly so we do not depend on
// a postMessage activation that can race (#2253) and strand the iframe blank
// (#2361, #2791).
const captureModeActive = drawOverlayOpen;
const useLazySrcDocTransport = !manualEditMode && !captureModeActive && useUrlLoadPreview;
const srcDocTransportContent = useLazySrcDocTransport ? lazySrcDocTransport : srcDoc;
const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank'; const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank';
const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => { const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
if (!canActivateSrcDocTransport({ if (!canActivateSrcDocTransport({
@ -4839,7 +4942,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
} }
const win = target?.contentWindow; const win = target?.contentWindow;
if (!win) return false; if (!win) return false;
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*'); win.postMessage({
type: 'od:srcdoc-transport-activate',
html: srcDoc,
navigation: previewNavigationRestoreRef.current,
}, '*');
activatedSrcDocTransportHtmlRef.current = srcDoc; activatedSrcDocTransportHtmlRef.current = srcDoc;
return true; return true;
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview, srcDocShellReady, boardMode]); }, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview, srcDocShellReady, boardMode]);
@ -4853,7 +4960,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
})) return false; })) return false;
const win = target?.contentWindow; const win = target?.contentWindow;
if (!win) return false; if (!win) return false;
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*'); win.postMessage({
type: 'od:srcdoc-transport-activate',
html: srcDoc,
navigation: previewNavigationRestoreRef.current,
}, '*');
activatedSrcDocTransportHtmlRef.current = srcDoc; activatedSrcDocTransportHtmlRef.current = srcDoc;
return true; return true;
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview]); }, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview]);
@ -4880,12 +4991,10 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
wasUrlLoadPreviewRef.current = false; wasUrlLoadPreviewRef.current = false;
activateSrcDocTransport(); activateSrcDocTransport();
}, [activateSrcDocTransport, useUrlLoadPreview]); }, [activateSrcDocTransport, useUrlLoadPreview]);
// Leaving Manual Edit can reuse a shell that just rendered edit HTML.
// Leaving Manual Edit swaps the iframe from a fully materialized srcDoc // Remount before the next activation; otherwise posting into the old
// document back to the lazy transport shell. Remount the shell before // document can mark the new HTML as activated before React replaces the
// activation; posting into the old edit document can mark the new HTML as // iframe, causing the dedupe check to suppress the real activation.
// activated, then React replaces the iframe with an empty shell and the
// dedupe check suppresses the real activation.
const prevManualEditModeRef = useRef(manualEditMode); const prevManualEditModeRef = useRef(manualEditMode);
useEffect(() => { useEffect(() => {
const wasInEditMode = prevManualEditModeRef.current; const wasInEditMode = prevManualEditModeRef.current;
@ -4894,11 +5003,10 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
if (wasInEditMode && !isNowInEditMode && !useUrlLoadPreview) { if (wasInEditMode && !isNowInEditMode && !useUrlLoadPreview) {
activatedSrcDocTransportHtmlRef.current = null; activatedSrcDocTransportHtmlRef.current = null;
setSrcDocShellReady(false);
setSrcDocTransportResetKey((key) => key + 1); setSrcDocTransportResetKey((key) => key + 1);
} }
}, [manualEditMode, useUrlLoadPreview]); }, [manualEditMode, useUrlLoadPreview]);
useEffect(() => { useEffect(() => {
restorePreviewScrollPosition(); restorePreviewScrollPosition();
}, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]); }, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]);
@ -4930,6 +5038,55 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
canvasTop: Number(data.canvasTop || 0), canvasTop: Number(data.canvasTop || 0),
}; };
} }
function onNavigationMessage(ev: MessageEvent) {
if (!isOurPreviewIframeSource(ev.source)) return;
const data = ev.data as {
type?: string;
href?: unknown;
pathname?: unknown;
search?: unknown;
hash?: unknown;
state?: unknown;
requestId?: unknown;
} | null;
if (!data || data.type !== 'od:preview-navigation') return;
const isActiveSource = isActivePreviewIframeSource(ev.source);
const pendingRequest = previewNavigationCaptureRequestRef.current;
const requestId = typeof data.requestId === 'string' ? data.requestId : '';
const isPendingRequestSource =
pendingRequest?.target === 'url'
? ev.source === urlPreviewIframeRef.current?.contentWindow
: pendingRequest?.target === 'srcdoc'
? ev.source === srcDocPreviewIframeRef.current?.contentWindow
: false;
const matchesPendingRequest = !!(
requestId &&
pendingRequest &&
pendingRequest.id === requestId &&
isPendingRequestSource
);
const isPendingCaptureReply = !!(
!isActiveSource &&
matchesPendingRequest
);
if (!isActiveSource && !isPendingCaptureReply) return;
const navigation = {
href: typeof data.href === 'string' ? data.href : '',
pathname: typeof data.pathname === 'string' ? data.pathname : '',
search: typeof data.search === 'string' ? data.search : '',
hash: typeof data.hash === 'string' ? data.hash : '',
state: data.state,
capturedAt: Date.now(),
};
if (matchesPendingRequest || (isActiveSource && pendingRequest)) {
previewNavigationCaptureRequestRef.current = null;
}
previewNavigationRef.current = navigation;
previewNavigationRestoreRef.current = navigation;
if (!isActiveSource) {
restorePreviewNavigationState(navigation);
}
}
function onRestoreRequest(ev: MessageEvent) { function onRestoreRequest(ev: MessageEvent) {
if (!isOurPreviewIframeSource(ev.source)) return; if (!isOurPreviewIframeSource(ev.source)) return;
if (!isActivePreviewIframeSource(ev.source)) return; if (!isActivePreviewIframeSource(ev.source)) return;
@ -4984,14 +5141,16 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
} }
} }
window.addEventListener('message', onMessage); window.addEventListener('message', onMessage);
window.addEventListener('message', onNavigationMessage);
window.addEventListener('message', onRestoreRequest); window.addEventListener('message', onRestoreRequest);
window.addEventListener('message', onDcViewportMessage); window.addEventListener('message', onDcViewportMessage);
return () => { return () => {
window.removeEventListener('message', onMessage); window.removeEventListener('message', onMessage);
window.removeEventListener('message', onNavigationMessage);
window.removeEventListener('message', onRestoreRequest); window.removeEventListener('message', onRestoreRequest);
window.removeEventListener('message', onDcViewportMessage); window.removeEventListener('message', onDcViewportMessage);
}; };
}, [isActivePreviewIframeSource, isOurPreviewIframeSource]); }, [isActivePreviewIframeSource, isOurPreviewIframeSource, restorePreviewNavigationState]);
useEffect(() => { useEffect(() => {
if (!effectiveDeck) { if (!effectiveDeck) {
@ -5498,7 +5657,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
function refreshSrcDocPreviewAfterManualEditExit() { function refreshSrcDocPreviewAfterManualEditExit() {
activatedSrcDocTransportHtmlRef.current = null; activatedSrcDocTransportHtmlRef.current = null;
setSrcDocShellReady(false); setSrcDocShellReadyKey(null);
setSrcDocTransportResetKey((key) => key + 1); setSrcDocTransportResetKey((key) => key + 1);
} }
@ -6189,6 +6348,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
} }
function activateBoard(nextTool?: BoardTool) { function activateBoard(nextTool?: BoardTool) {
capturePreviewNavigationState();
setMode('preview'); setMode('preview');
setBoardMode(true); setBoardMode(true);
if (nextTool) setBoardTool(nextTool); if (nextTool) setBoardTool(nextTool);
@ -6227,6 +6387,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
} }
capturePreviewScrollPosition(); capturePreviewScrollPosition();
const activateDraw = () => { const activateDraw = () => {
capturePreviewNavigationState();
setCommentPanelOpen(false); setCommentPanelOpen(false);
setCommentCreateMode(false); setCommentCreateMode(false);
setBoardMode(false); setBoardMode(false);
@ -6248,6 +6409,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
function activateCommentTool() { function activateCommentTool() {
fireArtifactToolbarClick('comment'); fireArtifactToolbarClick('comment');
capturePreviewScrollPosition(); capturePreviewScrollPosition();
capturePreviewNavigationState();
if (boardMode && !commentCreateMode && boardTool === 'inspect') { if (boardMode && !commentCreateMode && boardTool === 'inspect') {
setBoardMode(false); setBoardMode(false);
setCommentCreateMode(false); setCommentCreateMode(false);
@ -6277,6 +6439,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
function activateCommentCreateTool() { function activateCommentCreateTool() {
fireArtifactToolbarClick('comment'); fireArtifactToolbarClick('comment');
capturePreviewScrollPosition(); capturePreviewScrollPosition();
capturePreviewNavigationState();
if (boardMode && commentCreateMode) { if (boardMode && commentCreateMode) {
setBoardMode(false); setBoardMode(false);
setCommentCreateMode(false); setCommentCreateMode(false);
@ -6308,6 +6471,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
function activateManualEditTool() { function activateManualEditTool() {
fireArtifactToolbarClick('edit'); fireArtifactToolbarClick('edit');
capturePreviewScrollPosition(); capturePreviewScrollPosition();
capturePreviewNavigationState();
if (!manualEditMode) { if (!manualEditMode) {
setCommentPanelOpen(false); setCommentPanelOpen(false);
setCommentCreateMode(false); setCommentCreateMode(false);
@ -7294,7 +7458,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
<div className="artifact-preview-transport-stack"> <div className="artifact-preview-transport-stack">
{OD_PREVIEW_KEEP_ALIVE ? ( {OD_PREVIEW_KEEP_ALIVE ? (
<PooledIframe <PooledIframe
ref={urlPreviewIframeRef} ref={setUrlPreviewIframeRef}
cacheKey={urlPreviewKeepAliveKey} cacheKey={urlPreviewKeepAliveKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'} data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load" data-od-render-mode="url-load"
@ -7313,12 +7477,20 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
...dcViewportRef.current, ...dcViewportRef.current,
}, '*'); }, '*');
syncBridgeModes(frame); syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition(); if (useUrlLoadPreview) {
restorePreviewScrollPosition();
const navigation = previewNavigationRef.current;
if (navigation) {
previewNavigationRestoreRef.current = navigation;
const post = restorePreviewNavigationState(navigation);
post?.('url');
}
}
}} }}
/> />
) : ( ) : (
<iframe <iframe
ref={urlPreviewIframeRef} ref={setUrlPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'} data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load" data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'} data-od-active={useUrlLoadPreview ? 'true' : 'false'}
@ -7336,13 +7508,21 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
...dcViewportRef.current, ...dcViewportRef.current,
}, '*'); }, '*');
syncBridgeModes(frame); syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition(); if (useUrlLoadPreview) {
restorePreviewScrollPosition();
const navigation = previewNavigationRef.current;
if (navigation) {
previewNavigationRestoreRef.current = navigation;
const post = restorePreviewNavigationState(navigation);
post?.('url');
}
}
}} }}
/> />
)} )}
<iframe <iframe
key={srcDocTransportResetKey} key={srcDocTransportResetKey}
ref={srcDocPreviewIframeRef} ref={setSrcDocPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'} data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-render-mode="srcdoc" data-od-render-mode="srcdoc"
data-od-active={useUrlLoadPreview ? 'false' : 'true'} data-od-active={useUrlLoadPreview ? 'false' : 'true'}
@ -7350,7 +7530,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
tabIndex={useUrlLoadPreview ? -1 : 0} tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name} title={file.name}
sandbox="allow-scripts allow-downloads" sandbox="allow-scripts allow-downloads"
srcDoc={srcDocTransportContent} src={useLazySrcDocTransport ? srcDocTransportSrcUrl : undefined}
srcDoc={useLazySrcDocTransport ? undefined : srcDoc}
onLoad={() => { onLoad={() => {
const frame = srcDocPreviewIframeRef.current; const frame = srcDocPreviewIframeRef.current;
if (!useUrlLoadPreview) iframeRef.current = frame; if (!useUrlLoadPreview) iframeRef.current = frame;
@ -7388,7 +7569,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
srcDocFrameDedupeResetForRef.current = frame; srcDocFrameDedupeResetForRef.current = frame;
activatedSrcDocTransportHtmlRef.current = null; activatedSrcDocTransportHtmlRef.current = null;
} }
if (useLazySrcDocTransport) setSrcDocShellReady(true); if (useLazySrcDocTransport) setSrcDocShellReadyKey(srcDocShellInstanceKey);
activateLoadedSrcDocTransport(frame); activateLoadedSrcDocTransport(frame);
dcViewportRestoreAtRef.current = Date.now(); dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({ frame?.contentWindow?.postMessage({

View file

@ -25,6 +25,7 @@ export type SrcdocOptions = {
deck?: boolean; deck?: boolean;
baseHref?: string; baseHref?: string;
initialSlideIndex?: number; initialSlideIndex?: number;
initialNavigation?: SrcdocPreviewNavigation | null;
commentBridge?: boolean; commentBridge?: boolean;
inspectBridge?: boolean; inspectBridge?: boolean;
selectionBridge?: boolean; selectionBridge?: boolean;
@ -34,6 +35,14 @@ export type SrcdocOptions = {
previewFocusGuard?: boolean; previewFocusGuard?: boolean;
}; };
export type SrcdocPreviewNavigation = {
href?: string;
pathname?: string;
search?: string;
hash?: string;
state?: unknown;
};
export function buildSrcdoc( export function buildSrcdoc(
html: string, html: string,
options: SrcdocOptions = {} options: SrcdocOptions = {}
@ -55,7 +64,8 @@ export function buildSrcdoc(
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths; const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
const withShim = injectSandboxShim(withBase); const withShim = injectSandboxShim(withBase);
const withFocusGuard = options.previewFocusGuard ? injectPreviewFocusGuard(withShim) : withShim; const withFocusGuard = options.previewFocusGuard ? injectPreviewFocusGuard(withShim) : withShim;
const withDeck = options.deck ? injectDeckBridge(withFocusGuard, options.initialSlideIndex) : withFocusGuard; const withNavigation = injectPreviewNavigationRestore(withFocusGuard, options.initialNavigation ?? null);
const withDeck = options.deck ? injectDeckBridge(withNavigation, options.initialSlideIndex) : withNavigation;
// Comment + Inspect share an element-selection bridge: both pick a // Comment + Inspect share an element-selection bridge: both pick a
// [data-od-id] / [data-screen-label] node and route the host's reply // [data-od-id] / [data-screen-label] node and route the host's reply
// to either the comment popover (annotate) or the inspect panel // to either the comment popover (annotate) or the inspect panel
@ -762,6 +772,172 @@ function injectPreviewFocusGuard(doc: string): string {
return script + doc; return script + doc;
} }
function injectPreviewNavigationRestore(
doc: string,
navigation: SrcdocPreviewNavigation | null,
): string {
const initialHash = normalizePreviewNavigationHash(navigation?.hash);
const initialPathname = normalizePreviewNavigationPath(navigation?.pathname);
const initialSearch = normalizePreviewNavigationSearch(navigation?.search);
const initialState = safePreviewNavigationState(navigation?.state);
const hasInitialNavigation = !!navigation && (
initialHash !== '' ||
initialPathname !== '' ||
initialSearch !== '' ||
Object.prototype.hasOwnProperty.call(navigation, 'state')
);
const script = `<script data-od-preview-navigation-restore>(function(){
var initialHash = ${jsonForInlineScript(initialHash)};
var initialPathname = ${jsonForInlineScript(initialPathname)};
var initialSearch = ${jsonForInlineScript(initialSearch)};
var initialState = ${jsonForInlineScript(initialState)};
var lastPostedFingerprint = null;
function snapshot(){
return location.pathname + location.search + location.hash;
}
function stateFingerprint(value){
try {
if (value === undefined) return 'u';
return JSON.stringify(value);
} catch (_) {
return String(value);
}
}
function post(force, requestId){
try {
var href = snapshot();
var fingerprint = href + '\\n' + stateFingerprint(history.state);
if (force !== true && lastPostedFingerprint === fingerprint) return;
lastPostedFingerprint = fingerprint;
if (window.parent && window.parent !== window) {
var message = {
type: 'od:preview-navigation',
href: location.href,
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: history.state
};
if (typeof requestId === 'string') message.requestId = requestId;
window.parent.postMessage(message, '*');
}
} catch (_) {}
}
function isAboutSrcdoc(){
try { return String(location.href || '') === 'about:srcdoc' || String(location.protocol || '') === 'about:'; }
catch (_) { return false; }
}
function restore(){
var prevHash = location.hash;
if (isAboutSrcdoc()) {
if (initialHash && location.hash !== initialHash) {
try { location.hash = initialHash; } catch (_) {}
}
post();
return;
}
try {
var nextPath = initialPathname || location.pathname;
var nextSearch = initialSearch || '';
var nextHash = initialHash || '';
var nextUrl = nextPath + nextSearch + nextHash;
if (initialState !== undefined) history.replaceState(initialState, '', nextUrl);
else history.replaceState(history.state, '', nextUrl);
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}
if (location.hash !== prevHash) {
try {
var ev = typeof HashChangeEvent === 'function'
? new HashChangeEvent('hashchange', { oldURL: '', newURL: location.href })
: new Event('hashchange');
window.dispatchEvent(ev);
} catch (_) {}
}
} catch (_) {
if (initialHash && location.hash !== initialHash) {
try { location.hash = initialHash; } catch (__) {}
}
}
post();
}
function patch(name){
var original = history[name];
if (typeof original !== 'function') return;
history[name] = function(){
var result = original.apply(this, arguments);
post();
return result;
};
}
patch('pushState');
patch('replaceState');
window.addEventListener('hashchange', function(){ post(); });
window.addEventListener('popstate', function(){ post(); });
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data) return;
if (data.type === 'od:preview-navigation-request') {
post(true, typeof data.requestId === 'string' ? data.requestId : undefined);
return;
}
if (data.type === 'od:preview-navigation-restore') {
if (typeof data.hash === 'string') initialHash = data.hash;
if (typeof data.pathname === 'string') initialPathname = data.pathname;
if (typeof data.search === 'string') initialSearch = data.search;
if ('state' in data) initialState = data.state;
restore();
return;
}
});
if (${hasInitialNavigation ? 'true' : 'false'}) restore();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ post(); }, { once: true });
else setTimeout(function(){ post(); }, 0);
})();</script>`;
if (/<head[^>]*>/i.test(doc)) {
return doc.replace(/<head[^>]*>/i, (m) => `${m}${script}`);
}
if (/<body[^>]*>/i.test(doc)) {
return doc.replace(/<body[^>]*>/i, (m) => `${m}${script}`);
}
return script + doc;
}
function normalizePreviewNavigationHash(value: unknown): string {
if (typeof value !== 'string') return '';
if (value.length === 0) return '';
return value.startsWith('#') ? value : `#${value}`;
}
function normalizePreviewNavigationPath(value: unknown): string {
if (typeof value !== 'string') return '';
if (!value.startsWith('/')) return '';
return value;
}
function normalizePreviewNavigationSearch(value: unknown): string {
if (typeof value !== 'string') return '';
if (value.length === 0) return '';
return value.startsWith('?') ? value : `?${value}`;
}
function safePreviewNavigationState(value: unknown): unknown {
if (value === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(value));
} catch {
return undefined;
}
}
function jsonForInlineScript(value: unknown): string {
if (value === undefined) return 'undefined';
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
// Selection bridge: shared substrate for Comment mode and Inspect mode. // Selection bridge: shared substrate for Comment mode and Inspect mode.
// Both modes pick a [data-od-id] / [data-screen-label] element on click; // Both modes pick a [data-od-id] / [data-screen-label] element on click;
// the difference is what the host does with the selection — annotate // the difference is what the host does with the selection — annotate

View file

@ -23,6 +23,19 @@ vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
import { FileViewer } from '../../src/components/FileViewer'; import { FileViewer } from '../../src/components/FileViewer';
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
return calls
.map(([message]) => message)
.filter((message): message is {
type: 'od:srcdoc-transport-activate';
html: string;
} => {
if (typeof message !== 'object' || message === null) return false;
const data = message as { type?: unknown; html?: unknown };
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
});
}
function openManualTools() { function openManualTools() {
// Manual tools now live directly in the primary toolbar. // Manual tools now live directly in the primary toolbar.
} }
@ -256,6 +269,14 @@ describe('FileViewer manual edit history regressions', () => {
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc'); expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(panelState.props?.draft.fullSource).toContain('Hero'); expect(panelState.props?.draft.fullSource).toContain('Hero');
}); });
const srcDocFrame = getActivePreviewFrame();
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
fireEvent.load(srcDocFrame);
await waitFor(() => {
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
expect(activatedHtml).toContain('Hero');
});
act(() => { act(() => {
panelState.props?.onApplyPatch( panelState.props?.onApplyPatch(
{ id: 'hero', kind: 'set-text', value: 'Updated hero' }, { id: 'hero', kind: 'set-text', value: 'Updated hero' },
@ -266,7 +287,8 @@ describe('FileViewer manual edit history regressions', () => {
await waitFor(() => expect(savedSources).toHaveLength(1)); await waitFor(() => expect(savedSources).toHaveLength(1));
await waitFor(() => expect(panelState.props?.draft.fullSource).toContain('Updated hero')); await waitFor(() => expect(panelState.props?.draft.fullSource).toContain('Updated hero'));
await waitFor(() => { await waitFor(() => {
expect(getActivePreviewFrame().srcdoc).toContain('Updated hero'); const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
expect(activatedHtml).toContain('Updated hero');
}); });
}); });

View file

@ -2,7 +2,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { useLayoutEffect, useRef, useState } from 'react'; import { useLayoutEffect, useRef, useState, type RefObject } from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@ -79,13 +79,30 @@ function deferredResponse() {
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) { function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
return calls return calls
.map(([message]) => message) .map(([message]) => message)
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => { .filter((message): message is {
type: 'od:srcdoc-transport-activate';
html: string;
navigation?: unknown;
} => {
if (typeof message !== 'object' || message === null) return false; if (typeof message !== 'object' || message === null) return false;
const data = message as { type?: unknown; html?: unknown }; const data = message as { type?: unknown; html?: unknown };
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string'; return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
}); });
} }
function previewNavigationRequestId(calls: readonly (readonly unknown[])[]) {
const request = [...calls]
.reverse()
.map(([message]) => message)
.find((message): message is { type: 'od:preview-navigation-request'; requestId: string } => {
if (typeof message !== 'object' || message === null) return false;
const data = message as { type?: unknown; requestId?: unknown };
return data.type === 'od:preview-navigation-request' && typeof data.requestId === 'string';
});
if (!request) throw new Error('preview navigation request id not found');
return request.requestId;
}
function testRect(left: number, top: number, width: number, height: number): DOMRect { function testRect(left: number, top: number, width: number, height: number): DOMRect {
return { return {
x: left, x: left,
@ -523,7 +540,7 @@ describe('FileViewer SVG artifacts', () => {
const { container } = render(<Shell />); const { container } = render(<Shell />);
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll'); expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
fireEvent.click(screen.getByRole('button', { name: 'Leave project' })); fireEvent.click(screen.getByRole('button', { name: 'Leave project' }));
@ -531,7 +548,7 @@ describe('FileViewer SVG artifacts', () => {
expect(screen.getByTestId('home-view')).toBeTruthy(); expect(screen.getByTestId('home-view')).toBeTruthy();
const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe'); const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe');
expect(parkedFrame).toBe(firstFrame); expect(parkedFrame).toBe(firstFrame);
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll'); expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
fireEvent.click(screen.getByRole('button', { name: 'Return project' })); fireEvent.click(screen.getByRole('button', { name: 'Return project' }));
@ -678,7 +695,7 @@ describe('FileViewer SVG artifacts', () => {
expect(markup).toContain('data-od-render-mode="url-load"'); expect(markup).toContain('data-od-render-mode="url-load"');
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"'); expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"'); expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"');
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0&amp;odPreviewBridge=scroll"'); expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0&amp;odPreviewNav=1&amp;odPreviewBridge=scroll"');
expect(markup).toContain('sandbox="allow-scripts allow-downloads"'); expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
}); });
@ -746,20 +763,23 @@ describe('FileViewer SVG artifacts', () => {
expect(srcDocFrame).toBeTruthy(); expect(srcDocFrame).toBeTruthy();
expect(urlFrame?.getAttribute('data-od-active')).toBe('true'); expect(urlFrame?.getAttribute('data-od-active')).toBe('true');
expect(srcDocFrame?.getAttribute('data-od-active')).toBe('false'); expect(srcDocFrame?.getAttribute('data-od-active')).toBe('false');
expect(srcDocFrame?.srcdoc).toContain('data-od-lazy-srcdoc-transport'); expect(srcDocFrame?.getAttribute('src')).toContain('odSrcdocTransport=1');
expect(srcDocFrame?.srcdoc).not.toContain('__odArtifactBootCount'); expect(srcDocFrame?.srcdoc).not.toContain('__odArtifactBootCount');
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle')); fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const urlFrameAfter = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null; const urlFrameAfter = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
const srcDocFrameAfter = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null; const srcDocFrameAfter = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;
const srcDocPostMessageSpy = vi.spyOn(srcDocFrameAfter!.contentWindow!, 'postMessage');
fireEvent.load(srcDocFrameAfter!);
expect(urlFrameAfter).toBe(urlFrame); expect(urlFrameAfter).toBe(urlFrame);
expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false'); expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false');
expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank'); expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank');
expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true'); expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true');
expect(srcDocFrameAfter?.srcdoc).toContain('__odArtifactBootCount'); const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
expect(srcDocFrameAfter?.srcdoc).toContain('data-od-edit-bridge'); expect(activatedHtml).toContain('__odArtifactBootCount');
expect(activatedHtml).toContain('data-od-edit-bridge');
}); });
it('renders sandbox-shim artifacts on the srcdoc transport without entering edit mode (#2791)', () => { it('renders sandbox-shim artifacts on the srcdoc transport without entering edit mode (#2791)', () => {
@ -831,11 +851,563 @@ describe('FileViewer SVG artifacts', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Preview' })); fireEvent.click(screen.getByRole('tab', { name: 'Preview' }));
const activeFrame = await waitFor(() => {
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
return frame;
});
const postMessageSpy = vi.spyOn(activeFrame.contentWindow!, 'postMessage');
fireEvent.load(activeFrame);
await waitFor(() => { await waitFor(() => {
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; const activatedHtml = srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html ?? '';
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc'); expect(activatedHtml).toContain('data-od-edit-bridge');
expect(activeFrame.srcdoc).toContain('data-od-edit-bridge'); expect(activatedHtml).toContain('Hero');
expect(activeFrame.srcdoc).toContain('Hero'); });
});
it('restores captured hash navigation when switching a URL-loaded app into bridge mode', async () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/settings',
pathname: '/api/projects/project-1/raw/page.html',
search: '?v=1710000000&r=0&odPreviewNav=1',
hash: '#/settings',
state: { tab: 'settings' },
},
}));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const srcDocFrame = await waitFor(() => {
const activeFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-active')).toBe('true');
expect(activeFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
return activeFrame;
});
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
fireEvent.load(srcDocFrame);
await waitFor(() => {
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
expect(activatedHtml).toContain('data-od-preview-navigation-restore');
expect(activatedHtml).toContain('initialHash = "#/settings"');
expect(activatedHtml).toContain('"tab":"settings"');
});
});
it('loads the bridge iframe from the daemon srcdoc transport shell before restoring non-hash navigation', async () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1/dashboard?panel=metrics#daily',
pathname: '/api/projects/project-1/raw/page.html/dashboard',
search: '?panel=metrics',
hash: '#daily',
state: { panel: 'metrics' },
},
}));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => {
expect(srcDocFrame.getAttribute('data-od-active')).toBe('true');
});
expect(srcDocFrame.getAttribute('src')).toContain('/api/projects/project-1/raw/page.html?');
expect(srcDocFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
expect(srcDocFrame.srcdoc).not.toContain('initialPathname = "/api/projects/project-1/raw/page.html/dashboard"');
});
it('requests and restores navigation when opening a bridge tool switches to bridge mode', async () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
const urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
expect(urlPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-request',
requestId: expect.any(String),
}), '*');
const requestId = previewNavigationRequestId(urlPostMessageSpy.mock.calls);
const srcDocFrame = await waitFor(() => {
const activeFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-active')).toBe('true');
return activeFrame;
});
expect(srcDocFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1/dashboard?panel=metrics#daily',
pathname: '/api/projects/project-1/raw/page.html/dashboard',
search: '?panel=metrics',
hash: '#daily',
state: { panel: 'metrics' },
requestId,
},
}));
await waitFor(() => {
expect(srcDocPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-restore',
pathname: '/api/projects/project-1/raw/page.html/dashboard',
search: '?panel=metrics',
hash: '#daily',
state: { panel: 'metrics' },
}), '*');
});
});
it('restores a URL iframe navigation reply that arrives before passive ref alignment', () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
let restoreCallsDuringHandoff = 0;
let urlPostMessageSpy: ReturnType<typeof vi.spyOn> | null = null;
let srcDocPostMessageSpy: ReturnType<typeof vi.spyOn> | null = null;
function EarlyNavigationReplyProbe({
active,
hostRef,
}: {
active: boolean;
hostRef: RefObject<HTMLDivElement | null>;
}) {
const sentRef = useRef(false);
useLayoutEffect(() => {
if (!active || sentRef.current) return;
sentRef.current = true;
const root = hostRef.current;
const urlFrame = root?.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
const srcDocFrame = root?.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;
if (!urlFrame?.contentWindow || !srcDocFrame?.contentWindow) return;
srcDocPostMessageSpy ??= vi.spyOn(srcDocFrame.contentWindow, 'postMessage');
const requestId = urlPostMessageSpy ? previewNavigationRequestId(urlPostMessageSpy.mock.calls) : '';
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0/reports#q2',
pathname: '/api/projects/project-1/raw/page.html/reports',
search: '',
hash: '#q2',
state: { report: 'q2' },
requestId,
},
}));
restoreCallsDuringHandoff = srcDocPostMessageSpy.mock.calls.filter((call: unknown[]) => {
const message = call[0];
return (
typeof message === 'object' &&
message !== null &&
(message as { type?: unknown }).type === 'od:preview-navigation-restore'
);
}).length;
}, [active, hostRef]);
return null;
}
function Harness() {
const [replyDuringHandoff, setReplyDuringHandoff] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null);
return (
<div
ref={hostRef}
onClickCapture={(ev) => {
if ((ev.target as Element | null)?.closest('[data-testid="manual-edit-mode-toggle"]')) {
setReplyDuringHandoff(true);
}
}}
>
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>
<EarlyNavigationReplyProbe active={replyDuringHandoff} hostRef={hostRef} />
</div>
);
}
const { container } = render(<Harness />);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
expect(restoreCallsDuringHandoff).toBe(1);
expect(srcDocPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-restore',
pathname: '/api/projects/project-1/raw/page.html/reports',
hash: '#q2',
state: { report: 'q2' },
}), '*');
});
it('does not echo active preview navigation back into the same iframe', () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
const urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/settings',
pathname: '/api/projects/project-1/raw/page.html',
search: '?v=1710000000&r=0&odPreviewNav=1',
hash: '#/settings',
state: { tab: 'settings' },
},
}));
expect(urlPostMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-restore',
pathname: '/api/projects/project-1/raw/page.html',
search: '?v=1710000000&r=0&odPreviewNav=1',
hash: '#/settings',
state: { tab: 'settings' },
}), '*');
});
it('ignores stale navigation reports from an inactive preview iframe', async () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/settings',
pathname: '/api/projects/project-1/raw/page.html',
search: '?v=1710000000&r=0&odPreviewNav=1',
hash: '#/settings',
state: { tab: 'settings' },
},
}));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => {
expect(srcDocFrame.getAttribute('data-od-active')).toBe('true');
});
window.dispatchEvent(new MessageEvent('message', {
source: srcDocFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'about:srcdoc#/current',
pathname: '/',
search: '',
hash: '#/current',
state: { tab: 'current' },
},
}));
srcDocPostMessageSpy.mockClear();
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/stale',
pathname: '/api/projects/project-1/raw/page.html',
search: '?v=1710000000&r=0&odPreviewNav=1',
hash: '#/stale',
state: { tab: 'stale' },
},
}));
expect(srcDocPostMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-restore',
hash: '#/stale',
state: { tab: 'stale' },
}), '*');
});
it('ignores a delayed capture reply after the active bridge iframe reports newer navigation', async () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
const urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
expect(urlPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-request',
requestId: expect.any(String),
}), '*');
const requestId = previewNavigationRequestId(urlPostMessageSpy.mock.calls);
await waitFor(() => {
expect(srcDocFrame.getAttribute('data-od-active')).toBe('true');
});
window.dispatchEvent(new MessageEvent('message', {
source: srcDocFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'about:srcdoc#/current',
pathname: '/',
search: '',
hash: '#/current',
state: { tab: 'current' },
},
}));
srcDocPostMessageSpy.mockClear();
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/stale-requested',
pathname: '/api/projects/project-1/raw/page.html',
search: '?v=1710000000&r=0&odPreviewNav=1',
hash: '#/stale-requested',
state: { tab: 'stale-requested' },
requestId,
},
}));
expect(srcDocPostMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-restore',
hash: '#/stale-requested',
state: { tab: 'stale-requested' },
}), '*');
});
it('restores bridge navigation when returning to URL-loaded preview mode', async () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const srcDocFrame = await waitFor(() => {
const activeFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-active')).toBe('true');
return activeFrame;
});
window.dispatchEvent(new MessageEvent('message', {
source: srcDocFrame.contentWindow,
data: {
type: 'od:preview-navigation',
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1/editor?panel=layers#shape-3',
pathname: '/api/projects/project-1/raw/page.html/editor',
search: '?panel=layers',
hash: '#shape-3',
state: { panel: 'layers' },
},
}));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => {
expect(urlFrame.getAttribute('data-od-active')).toBe('true');
});
const activeUrlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
const urlPostMessageSpy = vi.spyOn(activeUrlFrame.contentWindow!, 'postMessage');
urlPostMessageSpy.mockClear();
fireEvent.load(activeUrlFrame);
await waitFor(() => {
expect(urlPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'od:preview-navigation-restore',
pathname: '/api/projects/project-1/raw/page.html/editor',
search: '?panel=layers',
hash: '#shape-3',
state: { panel: 'layers' },
}), '*');
}); });
}); });
@ -889,7 +1461,7 @@ describe('FileViewer SVG artifacts', () => {
const { container } = render(<Switcher />); const { container } = render(<Switcher />);
const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]'); const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
const initialFrame = getFrame(); const initialFrame = getFrame();
expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll'); expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
const observationsBeforeSwitch = observedCommittedSrcs.length; const observationsBeforeSwitch = observedCommittedSrcs.length;
fireEvent.click(screen.getByRole('button', { name: 'Switch file' })); fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
@ -897,9 +1469,9 @@ describe('FileViewer SVG artifacts', () => {
const nextFrame = getFrame(); const nextFrame = getFrame();
expect(nextFrame).toBeTruthy(); expect(nextFrame).toBeTruthy();
expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe( expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe(
'/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll', '/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll',
); );
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll'); expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
}); });
it('allows downloads in the in-tab HTML presentation iframe', async () => { it('allows downloads in the in-tab HTML presentation iframe', async () => {
@ -1870,7 +2442,7 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByPlaceholderText('Add a note for this mark')).toBeNull(); expect(screen.queryByPlaceholderText('Add a note for this mark')).toBeNull();
}); });
it('uses a materialized srcDoc bridge while the Draw bar is open', async () => { it('activates the srcDoc transport bridge while the Draw bar is open', async () => {
render( render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()} <FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>' liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
@ -1883,18 +2455,20 @@ describe('FileViewer tweaks toolbar', () => {
const frame = await waitFor(() => { const frame = await waitFor(() => {
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc'); expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(activeFrame.srcdoc).toContain('data-od-selection-bridge'); expect(activeFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
expect(activeFrame.srcdoc).toContain('data-od-snapshot-bridge');
expect(activeFrame.srcdoc).not.toContain('data-od-lazy-srcdoc-transport');
return activeFrame; return activeFrame;
}); });
const postMessageSpy = vi.spyOn(frame.contentWindow!, 'postMessage');
fireEvent.load(frame);
await waitFor(() => { await waitFor(() => {
expect(frame.srcdoc).toContain('data-od-id="hero"'); const activatedHtml = srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html ?? '';
expect(activatedHtml).toContain('data-od-selection-bridge');
expect(activatedHtml).toContain('data-od-id="hero"');
}); });
expect(screen.queryByRole('button', { name: 'Click' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Click' })).toBeNull();
expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy();
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc); expect(screen.getByTestId('artifact-preview-frame')).toBe(frame);
}); });
it('preserves URL-loaded preview scroll when opening Draw', async () => { it('preserves URL-loaded preview scroll when opening Draw', async () => {

View file

@ -0,0 +1,292 @@
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
function extractBridgeScript(html: string): string {
const match = html.match(
/<script\s+data-od-preview-navigation-restore>([\s\S]*?)<\/script>/,
);
if (!match || match[1] == null) {
throw new Error('preview-navigation-restore script not found');
}
return match[1];
}
interface BridgeHarness {
parentMessages: Array<Record<string, unknown>>;
replaceStateCalls: Array<{ state: unknown; url: string }>;
triggerEvent: (type: string, ev?: { data?: unknown }) => void;
replaceState: (state: unknown, url: string) => void;
state: { href: string; hash: string; pathname: string; search: string; state: unknown };
}
function runBridge(html: string, opts?: {
initialPathname?: string;
initialSearch?: string;
initialHash?: string;
}): BridgeHarness {
const script = extractBridgeScript(html);
const parentMessages: Array<Record<string, unknown>> = [];
type Listener = (ev: { data?: unknown }) => void;
const listeners = new Map<string, Listener[]>();
const replaceStateCalls: Array<{ state: unknown; url: string }> = [];
const state = {
href: 'about:srcdoc',
pathname: opts?.initialPathname ?? '/',
search: opts?.initialSearch ?? '',
hash: opts?.initialHash ?? '',
state: null as unknown,
};
const history = {
state: null as unknown,
pushState(s: unknown, _t: string, url: string) {
history.state = s;
applyUrl(url);
},
replaceState(s: unknown, _t: string, url: string) {
replaceStateCalls.push({ state: s, url });
history.state = s;
applyUrl(url);
},
};
function applyUrl(url: string) {
const hashIdx = url.indexOf('#');
const searchIdx = url.indexOf('?');
const pathEnd =
searchIdx >= 0
? searchIdx
: hashIdx >= 0
? hashIdx
: url.length;
state.pathname = url.slice(0, pathEnd) || state.pathname;
state.search =
searchIdx >= 0
? url.slice(searchIdx, hashIdx >= 0 ? hashIdx : url.length)
: '';
state.hash = hashIdx >= 0 ? url.slice(hashIdx) : '';
state.href = state.pathname + state.search + state.hash;
}
const location = {
get href() {
return state.href;
},
get pathname() {
return state.pathname;
},
get search() {
return state.search;
},
get hash() {
return state.hash;
},
set hash(v: string) {
state.hash = v.startsWith('#') ? v : `#${v}`;
state.href = state.pathname + state.search + state.hash;
// Browsers dispatch hashchange on direct location.hash assignment;
// mirror that so the bridge's listener path is exercised.
const lst = listeners.get('hashchange') ?? [];
for (const l of lst) l({ data: null });
},
};
const win = {
parent: {
postMessage: (data: unknown) => {
parentMessages.push(data as Record<string, unknown>);
},
},
addEventListener(type: string, listener: Listener) {
const list = listeners.get(type) ?? [];
list.push(listener);
listeners.set(type, list);
},
dispatchEvent(ev: { type: string; state?: unknown }) {
const list = listeners.get(ev.type) ?? [];
for (const l of list) l({ data: ev });
},
};
const documentMock = {
readyState: 'complete',
addEventListener: () => {},
};
const sandbox: Record<string, unknown> = {
window: win,
document: documentMock,
history,
location,
setTimeout: (fn: () => void) => fn(),
HashChangeEvent: class HashChangeEvent {
type: string;
oldURL: string;
newURL: string;
constructor(_type: string, init: { oldURL: string; newURL: string }) {
this.type = 'hashchange';
this.oldURL = init.oldURL;
this.newURL = init.newURL;
}
},
PopStateEvent: class PopStateEvent {
type: string;
state: unknown;
constructor(_type: string, init: { state: unknown }) {
this.type = 'popstate';
this.state = init.state;
}
},
};
// Wire window to itself so `window.parent !== window` evaluates correctly.
(win as unknown as { window: unknown }).window = win;
vm.createContext(sandbox);
vm.runInContext(script, sandbox);
return {
parentMessages,
replaceStateCalls,
triggerEvent: (type, ev) => {
const list = listeners.get(type) ?? [];
for (const l of list) l(ev ?? { data: null });
},
replaceState: (nextState, url) => {
history.replaceState(nextState, '', url);
},
state,
};
}
describe('injectPreviewNavigationRestore reporter (regression: bridge must report current route)', () => {
it('does not restore pathname/search/state while running as about:srcdoc', () => {
const html = buildSrcdoc('<html><body></body></html>', {
initialNavigation: {
pathname: '/api/projects/project-1/raw/page.html/dashboard',
search: '?panel=metrics',
hash: '#daily',
state: { panel: 'metrics' },
},
});
const bridge = runBridge(html, { initialPathname: '/base', initialSearch: '?old' });
expect(bridge.replaceStateCalls).toHaveLength(0);
expect(bridge.state.pathname).toBe('/base');
expect(bridge.state.search).toBe('?old');
expect(bridge.state.hash).toBe('#daily');
});
it('posts od:preview-navigation on boot when navigation is provided', () => {
const html = buildSrcdoc('<html><body></body></html>', {
initialNavigation: { pathname: '/a', search: '', hash: '#/dash', state: { tab: 'x' } },
});
const bridge = runBridge(html, { initialPathname: '/' });
const post = bridge.parentMessages.find((m) => m.type === 'od:preview-navigation');
expect(post).toMatchObject({
type: 'od:preview-navigation',
hash: '#/dash',
});
});
it('replies to od:preview-navigation-request even when nothing has changed', () => {
const html = buildSrcdoc('<html><body></body></html>', { initialNavigation: null });
const bridge = runBridge(html, { initialPathname: '/page' });
const before = bridge.parentMessages.length;
bridge.triggerEvent('message', { data: { type: 'od:preview-navigation-request', requestId: 'capture-1' } });
const posts = bridge.parentMessages.filter((m) => m.type === 'od:preview-navigation');
expect(posts).toHaveLength(before + 1);
expect(posts.at(-1)).toMatchObject({ requestId: 'capture-1' });
});
it('dedupes redundant od:preview-navigation reports to the same URL', () => {
const html = buildSrcdoc('<html><body></body></html>', {
initialNavigation: { hash: '#/section' },
});
const bridge = runBridge(html, { initialPathname: '/' });
const before = bridge.parentMessages.filter((m) => m.type === 'od:preview-navigation').length;
// Trigger hashchange listener; bridge will try to post but URL hasn't moved.
bridge.triggerEvent('hashchange');
bridge.triggerEvent('popstate');
const after = bridge.parentMessages.filter((m) => m.type === 'od:preview-navigation').length;
expect(after).toBe(before);
});
it('reports same-URL history state changes', () => {
const html = buildSrcdoc('<html><body></body></html>', { initialNavigation: null });
const bridge = runBridge(html, { initialPathname: '/page' });
const before = bridge.parentMessages.filter((m) => m.type === 'od:preview-navigation').length;
bridge.replaceState({ modal: 'settings' }, '/page');
const posts = bridge.parentMessages.filter((m) => m.type === 'od:preview-navigation');
expect(posts).toHaveLength(before + 1);
expect(posts.at(-1)).toMatchObject({
type: 'od:preview-navigation',
pathname: '/page',
state: { modal: 'settings' },
});
});
});
describe('injectPreviewNavigationRestore restore (regression: dispatch hashchange on hash-only restore)', () => {
it('dispatches a hashchange event when the restored URL changes the hash', () => {
const html = buildSrcdoc('<html><body></body></html>', {
initialNavigation: { hash: '#/section-after' },
});
let hashchangeFired = 0;
const script = extractBridgeScript(html);
type Listener = (ev: { data?: unknown }) => void;
const listeners = new Map<string, Listener[]>();
const sbState = {
href: '/page',
pathname: '/page',
search: '',
hash: '#/section-before',
state: null as unknown,
};
const sbHistory = {
state: null as unknown,
replaceState(s: unknown, _t: string, url: string) {
sbHistory.state = s;
const hashIdx = url.indexOf('#');
sbState.pathname = url.slice(0, hashIdx >= 0 ? hashIdx : url.length) || sbState.pathname;
sbState.hash = hashIdx >= 0 ? url.slice(hashIdx) : '';
sbState.href = sbState.pathname + sbState.search + sbState.hash;
},
pushState() {},
};
const sbWin = {
parent: { postMessage: () => {} },
addEventListener(t: string, l: Listener) {
const list = listeners.get(t) ?? [];
list.push(l);
listeners.set(t, list);
},
dispatchEvent(ev: { type: string }) {
if (ev.type === 'hashchange') hashchangeFired++;
},
};
(sbWin as unknown as { window: unknown }).window = sbWin;
const sandbox: Record<string, unknown> = {
window: sbWin,
document: { readyState: 'complete', addEventListener: () => {} },
history: sbHistory,
location: sbState,
setTimeout: (fn: () => void) => fn(),
HashChangeEvent: class HashChangeEvent {
type = 'hashchange';
oldURL: string;
newURL: string;
constructor(_t: string, init: { oldURL: string; newURL: string }) {
this.oldURL = init.oldURL;
this.newURL = init.newURL;
}
},
PopStateEvent: class PopStateEvent {
type = 'popstate';
state: unknown;
constructor(_t: string, init: { state: unknown }) {
this.state = init.state;
}
},
};
vm.createContext(sandbox);
vm.runInContext(script, sandbox);
expect(hashchangeFired).toBeGreaterThanOrEqual(1);
});
});

View file

@ -113,8 +113,8 @@ gsap.to(svgEl, { rotation: 90, svgOrigin: "100 100" });
## Stagger ## Stagger
Offset the animation of each item by 0.1 second like this: Offset the animation of each item by 0.1 second like this:
```javascript ```javascript
gsap.to(".item", { gsap.to(".item", {
y: -20, y: -20,
stagger: 0.1 stagger: 0.1
@ -157,7 +157,7 @@ base (out) .in .out .inOut
### Custom: use CustomEase (plugin) ### Custom: use CustomEase (plugin)
Simple cubic-bezier values (as used in CSS `cubic-bezier()`): Simple cubic-bezier values (as used in CSS `cubic-bezier()`):
```javascript ```javascript
const myEase = CustomEase.create("my-ease", ".17,.67,.83,.67"); const myEase = CustomEase.create("my-ease", ".17,.67,.83,.67");
@ -165,7 +165,7 @@ const myEase = CustomEase.create("my-ease", ".17,.67,.83,.67");
gsap.to(".item", {x: 100, ease: myEase, duration: 1}); gsap.to(".item", {x: 100, ease: myEase, duration: 1});
``` ```
Complex curve with any number of control points, described as normalized SVG path data: Complex curve with any number of control points, described as normalized SVG path data:
```javascript ```javascript
const myEase = CustomEase.create("hop", "M0,0 C0,0 0.056,0.442 0.175,0.442 0.294,0.442 0.332,0 0.332,0 0.332,0 0.414,1 0.671,1 0.991,1 1,0 1,0"); const myEase = CustomEase.create("hop", "M0,0 C0,0 0.056,0.442 0.175,0.442 0.294,0.442 0.332,0 0.332,0 0.332,0 0.414,1 0.671,1 0.991,1 1,0 1,0");

View file

@ -54,7 +54,7 @@ GSAP batches updates internally. When mixing GSAP with direct DOM reads/writes o
## Frequently updated properties (e.g. mouse followers) ## Frequently updated properties (e.g. mouse followers)
Prefer **gsap.quickTo()** for properties that are updated often (e.g. mouse-follower x/y). It reuses a single tween instead of creating new tweens on each update. Prefer **gsap.quickTo()** for properties that are updated often (e.g. mouse-follower x/y). It reuses a single tween instead of creating new tweens on each update.
```javascript ```javascript
let xTo = gsap.quickTo("#id", "x", { duration: 0.4, ease: "power3" }), let xTo = gsap.quickTo("#id", "x", { duration: 0.4, ease: "power3" }),

View file

@ -73,7 +73,7 @@ gsap.to(scrollContainer, { duration: 1, scrollTo: { x: "max" } });
### ScrollSmoother ### ScrollSmoother
Smooth scroll wrapper (smooths native scroll). Requires ScrollTrigger and a specific DOM structure (content wrapper + smooth wrapper). Use when smooth, momentum-style scroll is needed. See GSAP docs for setup; register after ScrollTrigger. DOM structure would look like: Smooth scroll wrapper (smooths native scroll). Requires ScrollTrigger and a specific DOM structure (content wrapper + smooth wrapper). Use when smooth, momentum-style scroll is needed. See GSAP docs for setup; register after ScrollTrigger. DOM structure would look like:
```html ```html
<body> <body>
@ -146,12 +146,12 @@ gsap.registerPlugin(Draggable, InertiaPlugin);
Draggable.create(".box", { type: "x,y", inertia: true }); Draggable.create(".box", { type: "x,y", inertia: true });
``` ```
Or track velocity of a property: Or track velocity of a property:
```javascript ```javascript
InertiaPlugin.track(".box", "x"); InertiaPlugin.track(".box", "x");
``` ```
Then use `"auto"` to continue the current velocity and glide to a stop: Then use `"auto"` to continue the current velocity and glide to a stop:
```javascript ```javascript
gsap.to(obj, { inertia: { x: "auto" } }); gsap.to(obj, { inertia: { x: "auto" } });
@ -252,7 +252,7 @@ gsap.to(".text", {
Reveals or hides the stroke of SVG elements by animating `stroke-dashoffset` / `stroke-dasharray`. Works on `<path>`, `<line>`, `<polyline>`, `<polygon>`, `<rect>`, `<ellipse>`. Use when “drawing” or “erasing” strokes. Reveals or hides the stroke of SVG elements by animating `stroke-dashoffset` / `stroke-dasharray`. Works on `<path>`, `<line>`, `<polyline>`, `<polygon>`, `<rect>`, `<ellipse>`. Use when “drawing” or “erasing” strokes.
**drawSVG value:** Describes the **visible segment** of the stroke along the path (start and end positions), not “animate from A to B over time.” Format: `"start end"` in percent or length. Examples: `"0% 100%"` = full stroke; `"20% 80%"` = stroke only between 20% and 80% (gaps at both ends). The tween animates from the elements **current** segment to the **target** segment — e.g. `gsap.to("#path", { drawSVG: "0% 100%" })` goes from whatever it is now to full stroke. Single value (e.g. `0`, `"100%"`) means start is 0: `"100%"` is equivalent to `"0% 100%"`. **drawSVG value:** Describes the **visible segment** of the stroke along the path (start and end positions), not “animate from A to B over time.” Format: `"start end"` in percent or length. Examples: `"0% 100%"` = full stroke; `"20% 80%"` = stroke only between 20% and 80% (gaps at both ends). The tween animates from the elements **current** segment to the **target** segment — e.g. `gsap.to("#path", { drawSVG: "0% 100%" })` goes from whatever it is now to full stroke. Single value (e.g. `0`, `"100%"`) means start is 0: `"100%"` is equivalent to `"0% 100%"`.
**Required:** The element must have a visible stroke — set `stroke` and `stroke-width` in CSS or as SVG attributes; otherwise nothing is drawn. **Required:** The element must have a visible stroke — set `stroke` and `stroke-width` in CSS or as SVG attributes; otherwise nothing is drawn.

View file

@ -66,7 +66,7 @@ By default, useGSAP() passes an empty dependency array to the internal useEffect
```javascript ```javascript
useGSAP(() => { useGSAP(() => {
// gsap code here, just like in a useEffect() // gsap code here, just like in a useEffect()
},{ },{
dependencies: [endX], // dependency array (optional) dependencies: [endX], // dependency array (optional)
scope: container, // scope selector text (optional, recommended) scope: container, // scope selector text (optional, recommended)
revertOnUpdate: true // causes the context to be reverted and the cleanup function to run every time the hook re-synchronizes (when any dependency changes) revertOnUpdate: true // causes the context to be reverted and the cleanup function to run every time the hook re-synchronizes (when any dependency changes)

View file

@ -238,13 +238,13 @@ A common pattern: **pin** a section, then as the user scrolls **vertically**, co
1. Pin the section (trigger = the full-viewport panel). 1. Pin the section (trigger = the full-viewport panel).
2. Build a tween that animates the inner contents **x** or **xPercent** (e.g. to `x: () => (targets.length - 1) * -window.innerWidth` or a negative `xPercent` to move left). Use **ease: "none"** on that tween. 2. Build a tween that animates the inner contents **x** or **xPercent** (e.g. to `x: () => (targets.length - 1) * -window.innerWidth` or a negative `xPercent` to move left). Use **ease: "none"** on that tween.
3. Attach ScrollTrigger to that tween with **pin: true**, **scrub: true** 3. Attach ScrollTrigger to that tween with **pin: true**, **scrub: true**
4. To trigger things based on the horizontal movement caused by that tween, set **containerAnimation** to that tween. 4. To trigger things based on the horizontal movement caused by that tween, set **containerAnimation** to that tween.
```javascript ```javascript
const scrollingEl = document.querySelector(".horizontal-el"); const scrollingEl = document.querySelector(".horizontal-el");
// Panel = pinned viewport-sized section. .horizontal-wrap = inner content that moves left. // Panel = pinned viewport-sized section. .horizontal-wrap = inner content that moves left.
const scrollTween = gsap.to(scrollingEl, { const scrollTween = gsap.to(scrollingEl, {
x: () => Math.min(0, window.innerWidth - scrollingEl.scrollWidth), x: () => Math.min(0, window.innerWidth - scrollingEl.scrollWidth),
ease: "none", // ease: "none" is required ease: "none", // ease: "none" is required
scrollTrigger: { scrollTrigger: {
@ -255,7 +255,7 @@ const scrollTween = gsap.to(scrollingEl, {
invalidateOnRefresh: true, invalidateOnRefresh: true,
scrub: true scrub: true
} }
}); });
// other tweens that trigger based on horizontal movement should reference the containerAnimation: // other tweens that trigger based on horizontal movement should reference the containerAnimation:
gsap.to(".nested-el-1", { gsap.to(".nested-el-1", {
@ -288,7 +288,7 @@ In React, use the `useGSAP()` hook (@gsap/react NPM package) to ensure proper cl
- ✅ **gsap.registerPlugin(ScrollTrigger)** once before any ScrollTrigger usage. - ✅ **gsap.registerPlugin(ScrollTrigger)** once before any ScrollTrigger usage.
- ✅ Call **ScrollTrigger.refresh()** after DOM/layout changes (new content, images, fonts) that affect trigger positions. Whenever the viewport is resized, `ScrollTrigger.refresh()` is automatically called (debounced 200ms) - ✅ Call **ScrollTrigger.refresh()** after DOM/layout changes (new content, images, fonts) that affect trigger positions. Whenever the viewport is resized, `ScrollTrigger.refresh()` is automatically called (debounced 200ms)
- ✅ In React, use the `useGSAP()` hook to ensure that all ScrollTriggers and GSAP animations are reverted and cleaned up when necessary, or use a `gsap.context()` to do it manually in a useEffect/useLayoutEffect cleanup function. - ✅ In React, use the `useGSAP()` hook to ensure that all ScrollTriggers and GSAP animations are reverted and cleaned up when necessary, or use a `gsap.context()` to do it manually in a useEffect/useLayoutEffect cleanup function.
- ✅ Use **scrub** for scroll-linked progress or **toggleActions** for discrete play/reverse; do not use both on the same trigger. - ✅ Use **scrub** for scroll-linked progress or **toggleActions** for discrete play/reverse; do not use both on the same trigger.
- ✅ For fake horizontal scroll with **containerAnimation**, use **ease: "none"** on the horizontal tween/timeline so scroll and horizontal position stay in sync. - ✅ For fake horizontal scroll with **containerAnimation**, use **ease: "none"** on the horizontal tween/timeline so scroll and horizontal position stay in sync.
- ✅ Create ScrollTriggers in the order they appear on the page (top to bottom, scroll 0 → max). When they are created in a different order (e.g. dynamic or async), set **refreshPriority** on each so they are refreshed in that same top-to-bottom order (first section on page = lower number). - ✅ Create ScrollTriggers in the order they appear on the page (top to bottom, scroll 0 → max). When they are created in a different order (e.g. dynamic or async), set **refreshPriority** on each so they are refreshed in that same top-to-bottom order (first section on page = lower number).