mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge c928a2a3c9 into 53fb175855
This commit is contained in:
commit
fafa413ed6
13 changed files with 1565 additions and 119 deletions
|
|
@ -659,8 +659,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
});
|
||||
}
|
||||
},
|
||||
get contaminated() {
|
||||
return guard.contaminated;
|
||||
get 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') {
|
||||
guard.sendDelta(data.delta.text);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (event === 'message_stop') {
|
||||
|
|
@ -862,13 +862,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
return true;
|
||||
}
|
||||
const delta = extractOpenAIText(data);
|
||||
if (delta) {
|
||||
guard.sendDelta(delta);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
if (delta) {
|
||||
guard.sendDelta(delta);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
|
@ -1017,12 +1017,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
return true;
|
||||
}
|
||||
const delta = extractOpenAIText(data);
|
||||
if (delta) { guard.sendDelta(delta);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
if (delta) { guard.sendDelta(delta);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
|
@ -1122,12 +1122,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
return true;
|
||||
}
|
||||
const delta = extractGeminiText(data);
|
||||
if (delta) { guard.sendDelta(delta);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
if (delta) { guard.sendDelta(delta);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const blockMessage = extractGeminiBlockMessage(data);
|
||||
if (blockMessage) {
|
||||
|
|
@ -1215,13 +1215,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
return true;
|
||||
}
|
||||
const content = data.message?.content;
|
||||
if (typeof content === 'string' && content) {
|
||||
guard.sendDelta(content);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
if (typeof content === 'string' && content) {
|
||||
guard.sendDelta(content);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
ended = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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.
|
||||
if (typeof delta.content === 'string' && delta.content) {
|
||||
guard.sendDelta(delta.content);
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
return true;
|
||||
if (guard.contaminated) {
|
||||
sse.send('end', {});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'> {}
|
||||
|
||||
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(
|
||||
projectsRoot: string,
|
||||
project: any,
|
||||
|
|
@ -1345,13 +1495,27 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
}
|
||||
|
||||
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
|
||||
if (
|
||||
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
||||
/^text\/html(?:;|$)/i.test(file.mime)
|
||||
) {
|
||||
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
|
||||
const isHtmlFile = /^text\/html(?:;|$)/i.test(file.mime);
|
||||
if (isHtmlFile && shouldInjectPreviewNavigationBridge(req.query.odSrcdocTransport)) {
|
||||
res.type(file.mime).send(buildSrcdocTransportShell());
|
||||
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);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
|
|
|
|||
|
|
@ -231,6 +231,34 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
|
|||
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 () => {
|
||||
const plain = await fetch(rawUrl('page.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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const res = await fetch(rawUrl('missing.mp4'));
|
||||
expect(res.status).toBe(404);
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import {
|
|||
} from '../runtime/exports';
|
||||
import { buildReactComponentSrcdoc } from '../runtime/react-component';
|
||||
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
|
||||
import { buildLazySrcdocTransport, buildSrcdoc, canActivateSrcDocTransport } from '../runtime/srcdoc';
|
||||
import { buildSrcdoc, canActivateSrcDocTransport, type SrcdocPreviewNavigation } from '../runtime/srcdoc';
|
||||
import {
|
||||
hasUrlModeBridge,
|
||||
htmlNeedsFocusGuard,
|
||||
|
|
@ -142,6 +142,14 @@ export type ManualEditPendingStyleSave = {
|
|||
version: number;
|
||||
};
|
||||
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 CommentPreviewCanvasOptions = {
|
||||
boardMode: boolean;
|
||||
|
|
@ -4271,6 +4279,10 @@ function HtmlViewer({
|
|||
canvasTop: 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({
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
|
@ -4405,6 +4417,63 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
() => (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 [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
|
||||
const [hoveredPodMemberId, setHoveredPodMemberId] = useState<string | null>(null);
|
||||
|
|
@ -4721,7 +4790,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
needsFocusGuard,
|
||||
});
|
||||
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],
|
||||
);
|
||||
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
|
||||
|
|
@ -4733,10 +4806,33 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
: basePreviewSrcUrl;
|
||||
useEffect(() => {
|
||||
setPreviewSrcUrl(basePreviewSrcUrl);
|
||||
previewNavigationRef.current = null;
|
||||
previewNavigationRestoreRef.current = null;
|
||||
previewNavigationCaptureRequestRef.current = null;
|
||||
}, [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(() => {
|
||||
iframeRef.current = useUrlLoadPreview ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current;
|
||||
}, [useUrlLoadPreview]);
|
||||
alignActivePreviewIframeRef();
|
||||
}, [alignActivePreviewIframeRef, useUrlLoadPreview]);
|
||||
useEffect(() => {
|
||||
const navigation = previewNavigationRef.current;
|
||||
if (!navigation) return;
|
||||
previewNavigationRestoreRef.current = navigation;
|
||||
const post = restorePreviewNavigationState(navigation);
|
||||
post?.(useUrlLoadPreview ? 'url' : 'srcdoc');
|
||||
}, [restorePreviewNavigationState, useUrlLoadPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filesRefreshKey === 0) return;
|
||||
|
|
@ -4769,24 +4865,38 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
deck: effectiveDeck,
|
||||
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
|
||||
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
|
||||
initialNavigation: previewNavigationRestoreRef.current,
|
||||
selectionBridge: true,
|
||||
editBridge: manualEditMode,
|
||||
paletteBridge: false,
|
||||
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 [srcDocShellReady, setSrcDocShellReady] = useState(false);
|
||||
const srcDocShellInstanceKey = `${srcDocTransportResetKey}:${srcDocTransportSrcUrl}`;
|
||||
const [srcDocShellReadyKey, setSrcDocShellReadyKey] = useState<string | null>(null);
|
||||
const srcDocShellReady = srcDocShellReadyKey === srcDocShellInstanceKey;
|
||||
const wasUrlLoadPreviewRef = useRef(useUrlLoadPreview);
|
||||
const [hasLazySrcDocTransport, setHasLazySrcDocTransport] = useState(useUrlLoadPreview);
|
||||
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(() => {
|
||||
setSrcDocShellReady(false);
|
||||
}, [srcDocTransportResetKey]);
|
||||
if (useUrlLoadPreview) setHasLazySrcDocTransport(true);
|
||||
}, [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
|
||||
// what fixes the #2253 race: opening Tweaks right after a key-driven
|
||||
// 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;
|
||||
const data = ev.data as { type?: string } | null;
|
||||
if (data?.type !== 'od:srcdoc-transport-ready') return;
|
||||
setSrcDocShellReady(true);
|
||||
setSrcDocShellReadyKey(srcDocShellInstanceKey);
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, []);
|
||||
// Lazy transport preloads an empty shell only while URL-load is the active
|
||||
// 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;
|
||||
}, [srcDocShellInstanceKey]);
|
||||
const useLazySrcDocTransport = useUrlLoadPreview || hasLazySrcDocTransport;
|
||||
const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank';
|
||||
const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
|
||||
if (!canActivateSrcDocTransport({
|
||||
|
|
@ -4839,7 +4942,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
}
|
||||
const win = target?.contentWindow;
|
||||
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;
|
||||
return true;
|
||||
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview, srcDocShellReady, boardMode]);
|
||||
|
|
@ -4853,7 +4960,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
})) return false;
|
||||
const win = target?.contentWindow;
|
||||
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;
|
||||
return true;
|
||||
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview]);
|
||||
|
|
@ -4880,12 +4991,10 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
wasUrlLoadPreviewRef.current = false;
|
||||
activateSrcDocTransport();
|
||||
}, [activateSrcDocTransport, useUrlLoadPreview]);
|
||||
|
||||
// Leaving Manual Edit swaps the iframe from a fully materialized srcDoc
|
||||
// document back to the lazy transport shell. Remount the shell before
|
||||
// activation; posting into the old edit document can mark the new HTML as
|
||||
// activated, then React replaces the iframe with an empty shell and the
|
||||
// dedupe check suppresses the real activation.
|
||||
// Leaving Manual Edit can reuse a shell that just rendered edit HTML.
|
||||
// Remount before the next activation; otherwise posting into the old
|
||||
// document can mark the new HTML as activated before React replaces the
|
||||
// iframe, causing the dedupe check to suppress the real activation.
|
||||
const prevManualEditModeRef = useRef(manualEditMode);
|
||||
useEffect(() => {
|
||||
const wasInEditMode = prevManualEditModeRef.current;
|
||||
|
|
@ -4894,11 +5003,10 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
|
||||
if (wasInEditMode && !isNowInEditMode && !useUrlLoadPreview) {
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
setSrcDocShellReady(false);
|
||||
setSrcDocTransportResetKey((key) => key + 1);
|
||||
}
|
||||
}, [manualEditMode, useUrlLoadPreview]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
restorePreviewScrollPosition();
|
||||
}, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]);
|
||||
|
|
@ -4930,6 +5038,55 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
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) {
|
||||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||||
if (!isActivePreviewIframeSource(ev.source)) return;
|
||||
|
|
@ -4984,14 +5141,16 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
}
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
window.addEventListener('message', onNavigationMessage);
|
||||
window.addEventListener('message', onRestoreRequest);
|
||||
window.addEventListener('message', onDcViewportMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage);
|
||||
window.removeEventListener('message', onNavigationMessage);
|
||||
window.removeEventListener('message', onRestoreRequest);
|
||||
window.removeEventListener('message', onDcViewportMessage);
|
||||
};
|
||||
}, [isActivePreviewIframeSource, isOurPreviewIframeSource]);
|
||||
}, [isActivePreviewIframeSource, isOurPreviewIframeSource, restorePreviewNavigationState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveDeck) {
|
||||
|
|
@ -5498,7 +5657,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
|
||||
function refreshSrcDocPreviewAfterManualEditExit() {
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
setSrcDocShellReady(false);
|
||||
setSrcDocShellReadyKey(null);
|
||||
setSrcDocTransportResetKey((key) => key + 1);
|
||||
}
|
||||
|
||||
|
|
@ -6189,6 +6348,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
}
|
||||
|
||||
function activateBoard(nextTool?: BoardTool) {
|
||||
capturePreviewNavigationState();
|
||||
setMode('preview');
|
||||
setBoardMode(true);
|
||||
if (nextTool) setBoardTool(nextTool);
|
||||
|
|
@ -6227,6 +6387,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
}
|
||||
capturePreviewScrollPosition();
|
||||
const activateDraw = () => {
|
||||
capturePreviewNavigationState();
|
||||
setCommentPanelOpen(false);
|
||||
setCommentCreateMode(false);
|
||||
setBoardMode(false);
|
||||
|
|
@ -6248,6 +6409,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
function activateCommentTool() {
|
||||
fireArtifactToolbarClick('comment');
|
||||
capturePreviewScrollPosition();
|
||||
capturePreviewNavigationState();
|
||||
if (boardMode && !commentCreateMode && boardTool === 'inspect') {
|
||||
setBoardMode(false);
|
||||
setCommentCreateMode(false);
|
||||
|
|
@ -6277,6 +6439,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
function activateCommentCreateTool() {
|
||||
fireArtifactToolbarClick('comment');
|
||||
capturePreviewScrollPosition();
|
||||
capturePreviewNavigationState();
|
||||
if (boardMode && commentCreateMode) {
|
||||
setBoardMode(false);
|
||||
setCommentCreateMode(false);
|
||||
|
|
@ -6308,6 +6471,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
function activateManualEditTool() {
|
||||
fireArtifactToolbarClick('edit');
|
||||
capturePreviewScrollPosition();
|
||||
capturePreviewNavigationState();
|
||||
if (!manualEditMode) {
|
||||
setCommentPanelOpen(false);
|
||||
setCommentCreateMode(false);
|
||||
|
|
@ -7294,7 +7458,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
<div className="artifact-preview-transport-stack">
|
||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||
<PooledIframe
|
||||
ref={urlPreviewIframeRef}
|
||||
ref={setUrlPreviewIframeRef}
|
||||
cacheKey={urlPreviewKeepAliveKey}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
|
|
@ -7313,12 +7477,20 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
if (useUrlLoadPreview) {
|
||||
restorePreviewScrollPosition();
|
||||
const navigation = previewNavigationRef.current;
|
||||
if (navigation) {
|
||||
previewNavigationRestoreRef.current = navigation;
|
||||
const post = restorePreviewNavigationState(navigation);
|
||||
post?.('url');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
ref={urlPreviewIframeRef}
|
||||
ref={setUrlPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
|
|
@ -7336,13 +7508,21 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
if (useUrlLoadPreview) {
|
||||
restorePreviewScrollPosition();
|
||||
const navigation = previewNavigationRef.current;
|
||||
if (navigation) {
|
||||
previewNavigationRestoreRef.current = navigation;
|
||||
const post = restorePreviewNavigationState(navigation);
|
||||
post?.('url');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
key={srcDocTransportResetKey}
|
||||
ref={srcDocPreviewIframeRef}
|
||||
ref={setSrcDocPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||
data-od-render-mode="srcdoc"
|
||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||
|
|
@ -7350,7 +7530,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
srcDoc={srcDocTransportContent}
|
||||
src={useLazySrcDocTransport ? srcDocTransportSrcUrl : undefined}
|
||||
srcDoc={useLazySrcDocTransport ? undefined : srcDoc}
|
||||
onLoad={() => {
|
||||
const frame = srcDocPreviewIframeRef.current;
|
||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||
|
|
@ -7388,7 +7569,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
srcDocFrameDedupeResetForRef.current = frame;
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
}
|
||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||
if (useLazySrcDocTransport) setSrcDocShellReadyKey(srcDocShellInstanceKey);
|
||||
activateLoadedSrcDocTransport(frame);
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export type SrcdocOptions = {
|
|||
deck?: boolean;
|
||||
baseHref?: string;
|
||||
initialSlideIndex?: number;
|
||||
initialNavigation?: SrcdocPreviewNavigation | null;
|
||||
commentBridge?: boolean;
|
||||
inspectBridge?: boolean;
|
||||
selectionBridge?: boolean;
|
||||
|
|
@ -34,6 +35,14 @@ export type SrcdocOptions = {
|
|||
previewFocusGuard?: boolean;
|
||||
};
|
||||
|
||||
export type SrcdocPreviewNavigation = {
|
||||
href?: string;
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
state?: unknown;
|
||||
};
|
||||
|
||||
export function buildSrcdoc(
|
||||
html: string,
|
||||
options: SrcdocOptions = {}
|
||||
|
|
@ -55,7 +64,8 @@ export function buildSrcdoc(
|
|||
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
|
||||
const withShim = injectSandboxShim(withBase);
|
||||
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
|
||||
// [data-od-id] / [data-screen-label] node and route the host's reply
|
||||
// to either the comment popover (annotate) or the inspect panel
|
||||
|
|
@ -762,6 +772,172 @@ function injectPreviewFocusGuard(doc: string): string {
|
|||
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.
|
||||
// Both modes pick a [data-od-id] / [data-screen-label] element on click;
|
||||
// the difference is what the host does with the selection — annotate
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
|
|||
|
||||
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() {
|
||||
// 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(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(() => {
|
||||
panelState.props?.onApplyPatch(
|
||||
{ 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(panelState.props?.draft.fullSource).toContain('Updated hero'));
|
||||
await waitFor(() => {
|
||||
expect(getActivePreviewFrame().srcdoc).toContain('Updated hero');
|
||||
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||
expect(activatedHtml).toContain('Updated hero');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { readFileSync } from 'node:fs';
|
||||
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 { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
@ -79,13 +79,30 @@ function deferredResponse() {
|
|||
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
|
||||
return calls
|
||||
.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;
|
||||
const data = message as { type?: unknown; html?: unknown };
|
||||
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 {
|
||||
return {
|
||||
x: left,
|
||||
|
|
@ -523,7 +540,7 @@ describe('FileViewer SVG artifacts', () => {
|
|||
const { container } = render(<Shell />);
|
||||
|
||||
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' }));
|
||||
|
||||
|
|
@ -531,7 +548,7 @@ describe('FileViewer SVG artifacts', () => {
|
|||
expect(screen.getByTestId('home-view')).toBeTruthy();
|
||||
const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe');
|
||||
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' }));
|
||||
|
||||
|
|
@ -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" data-od-active="true"');
|
||||
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&r=0&odPreviewBridge=scroll"');
|
||||
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll"');
|
||||
expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
|
||||
});
|
||||
|
||||
|
|
@ -746,20 +763,23 @@ describe('FileViewer SVG artifacts', () => {
|
|||
expect(srcDocFrame).toBeTruthy();
|
||||
expect(urlFrame?.getAttribute('data-od-active')).toBe('true');
|
||||
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');
|
||||
|
||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||
|
||||
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 srcDocPostMessageSpy = vi.spyOn(srcDocFrameAfter!.contentWindow!, 'postMessage');
|
||||
fireEvent.load(srcDocFrameAfter!);
|
||||
|
||||
expect(urlFrameAfter).toBe(urlFrame);
|
||||
expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false');
|
||||
expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank');
|
||||
expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true');
|
||||
expect(srcDocFrameAfter?.srcdoc).toContain('__odArtifactBootCount');
|
||||
expect(srcDocFrameAfter?.srcdoc).toContain('data-od-edit-bridge');
|
||||
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||
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)', () => {
|
||||
|
|
@ -831,11 +851,563 @@ describe('FileViewer SVG artifacts', () => {
|
|||
|
||||
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(() => {
|
||||
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||
expect(activeFrame.srcdoc).toContain('data-od-edit-bridge');
|
||||
expect(activeFrame.srcdoc).toContain('Hero');
|
||||
const activatedHtml = srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||
expect(activatedHtml).toContain('data-od-edit-bridge');
|
||||
expect(activatedHtml).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 getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
|
||||
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;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
|
||||
|
|
@ -897,9 +1469,9 @@ describe('FileViewer SVG artifacts', () => {
|
|||
const nextFrame = getFrame();
|
||||
expect(nextFrame).toBeTruthy();
|
||||
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 () => {
|
||||
|
|
@ -1870,7 +2442,7 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
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(
|
||||
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
|
||||
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 activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||
expect(activeFrame.srcdoc).toContain('data-od-selection-bridge');
|
||||
expect(activeFrame.srcdoc).toContain('data-od-snapshot-bridge');
|
||||
expect(activeFrame.srcdoc).not.toContain('data-od-lazy-srcdoc-transport');
|
||||
expect(activeFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
|
||||
return activeFrame;
|
||||
});
|
||||
const postMessageSpy = vi.spyOn(frame.contentWindow!, 'postMessage');
|
||||
fireEvent.load(frame);
|
||||
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.getByRole('button', { name: 'Undo' })).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 () => {
|
||||
|
|
|
|||
292
apps/web/tests/runtime/srcdoc-preview-navigation.test.ts
Normal file
292
apps/web/tests/runtime/srcdoc-preview-navigation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -113,8 +113,8 @@ gsap.to(svgEl, { rotation: 90, svgOrigin: "100 100" });
|
|||
|
||||
## Stagger
|
||||
|
||||
Offset the animation of each item by 0.1 second like this:
|
||||
```javascript
|
||||
Offset the animation of each item by 0.1 second like this:
|
||||
```javascript
|
||||
gsap.to(".item", {
|
||||
y: -20,
|
||||
stagger: 0.1
|
||||
|
|
@ -157,7 +157,7 @@ base (out) .in .out .inOut
|
|||
|
||||
### 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
|
||||
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});
|
||||
```
|
||||
|
||||
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
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ GSAP batches updates internally. When mixing GSAP with direct DOM reads/writes o
|
|||
|
||||
## 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
|
||||
let xTo = gsap.quickTo("#id", "x", { duration: 0.4, ease: "power3" }),
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ gsap.to(scrollContainer, { duration: 1, scrollTo: { x: "max" } });
|
|||
|
||||
### 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
|
||||
<body>
|
||||
|
|
@ -146,12 +146,12 @@ gsap.registerPlugin(Draggable, InertiaPlugin);
|
|||
Draggable.create(".box", { type: "x,y", inertia: true });
|
||||
```
|
||||
|
||||
Or track velocity of a property:
|
||||
Or track velocity of a property:
|
||||
```javascript
|
||||
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
|
||||
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.
|
||||
|
||||
**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 element’s **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 element’s **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ By default, useGSAP() passes an empty dependency array to the internal useEffect
|
|||
```javascript
|
||||
useGSAP(() => {
|
||||
// gsap code here, just like in a useEffect()
|
||||
},{
|
||||
},{
|
||||
dependencies: [endX], // dependency array (optional)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
2. Build a tween that animates the inner content’s **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**
|
||||
4. To trigger things based on the horizontal movement caused by that tween, set **containerAnimation** to that tween.
|
||||
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.
|
||||
|
||||
```javascript
|
||||
const scrollingEl = document.querySelector(".horizontal-el");
|
||||
// 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),
|
||||
ease: "none", // ease: "none" is required
|
||||
scrollTrigger: {
|
||||
|
|
@ -255,7 +255,7 @@ const scrollTween = gsap.to(scrollingEl, {
|
|||
invalidateOnRefresh: true,
|
||||
scrub: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// other tweens that trigger based on horizontal movement should reference the containerAnimation:
|
||||
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.
|
||||
- ✅ 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.
|
||||
- ✅ 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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue