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() {
|
get contaminated() {
|
||||||
return guard.contaminated;
|
return guard.contaminated;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -748,10 +748,10 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
}
|
}
|
||||||
if (event === 'content_block_delta' && typeof data.delta?.text === 'string') {
|
if (event === 'content_block_delta' && typeof data.delta?.text === 'string') {
|
||||||
guard.sendDelta(data.delta.text);
|
guard.sendDelta(data.delta.text);
|
||||||
if (guard.contaminated) {
|
if (guard.contaminated) {
|
||||||
sse.send('end', {});
|
sse.send('end', {});
|
||||||
ended = true;
|
ended = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event === 'message_stop') {
|
if (event === 'message_stop') {
|
||||||
|
|
@ -862,13 +862,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const delta = extractOpenAIText(data);
|
const delta = extractOpenAIText(data);
|
||||||
if (delta) {
|
if (delta) {
|
||||||
guard.sendDelta(delta);
|
guard.sendDelta(delta);
|
||||||
if (guard.contaminated) {
|
if (guard.contaminated) {
|
||||||
sse.send('end', {});
|
sse.send('end', {});
|
||||||
ended = true;
|
ended = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
@ -1017,12 +1017,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const delta = extractOpenAIText(data);
|
const delta = extractOpenAIText(data);
|
||||||
if (delta) { guard.sendDelta(delta);
|
if (delta) { guard.sendDelta(delta);
|
||||||
if (guard.contaminated) {
|
if (guard.contaminated) {
|
||||||
sse.send('end', {});
|
sse.send('end', {});
|
||||||
ended = true;
|
ended = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
@ -1122,12 +1122,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const delta = extractGeminiText(data);
|
const delta = extractGeminiText(data);
|
||||||
if (delta) { guard.sendDelta(delta);
|
if (delta) { guard.sendDelta(delta);
|
||||||
if (guard.contaminated) {
|
if (guard.contaminated) {
|
||||||
sse.send('end', {});
|
sse.send('end', {});
|
||||||
ended = true;
|
ended = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const blockMessage = extractGeminiBlockMessage(data);
|
const blockMessage = extractGeminiBlockMessage(data);
|
||||||
if (blockMessage) {
|
if (blockMessage) {
|
||||||
|
|
@ -1215,13 +1215,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const content = data.message?.content;
|
const content = data.message?.content;
|
||||||
if (typeof content === 'string' && content) {
|
if (typeof content === 'string' && content) {
|
||||||
guard.sendDelta(content);
|
guard.sendDelta(content);
|
||||||
if (guard.contaminated) {
|
if (guard.contaminated) {
|
||||||
sse.send('end', {});
|
sse.send('end', {});
|
||||||
ended = true;
|
ended = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
@ -1415,9 +1415,9 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
// we want the user to see whatever the model decided to say.
|
// we want the user to see whatever the model decided to say.
|
||||||
if (typeof delta.content === 'string' && delta.content) {
|
if (typeof delta.content === 'string' && delta.content) {
|
||||||
guard.sendDelta(delta.content);
|
guard.sendDelta(delta.content);
|
||||||
if (guard.contaminated) {
|
if (guard.contaminated) {
|
||||||
sse.send('end', {});
|
sse.send('end', {});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,156 @@ import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
||||||
|
|
||||||
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
|
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
|
||||||
|
|
||||||
|
function shouldInjectPreviewNavigationBridge(queryValue: unknown): boolean {
|
||||||
|
if (Array.isArray(queryValue)) return queryValue.some(shouldInjectPreviewNavigationBridge);
|
||||||
|
if (queryValue === true) return true;
|
||||||
|
if (typeof queryValue !== 'string') return false;
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(queryValue.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectPreviewNavigationBridge(html: string): string {
|
||||||
|
const script = `<script data-od-preview-navigation-bridge>(function(){
|
||||||
|
function state(requestId){
|
||||||
|
var message = {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: location.href,
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search,
|
||||||
|
hash: location.hash,
|
||||||
|
state: history.state
|
||||||
|
};
|
||||||
|
if (typeof requestId === 'string') message.requestId = requestId;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
function post(requestId){
|
||||||
|
try { window.parent.postMessage(state(requestId), '*'); } catch (_) {}
|
||||||
|
}
|
||||||
|
function restore(data){
|
||||||
|
var prevHash = location.hash;
|
||||||
|
try {
|
||||||
|
var hash = typeof data.hash === 'string' ? data.hash : location.hash;
|
||||||
|
var pathname = typeof data.pathname === 'string' && data.pathname.charAt(0) === '/' ? data.pathname : location.pathname;
|
||||||
|
var search = typeof data.search === 'string' ? data.search : location.search;
|
||||||
|
history.replaceState('state' in data ? data.state : history.state, '', pathname + search + hash);
|
||||||
|
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (__) {}
|
||||||
|
if (location.hash !== prevHash) {
|
||||||
|
try {
|
||||||
|
var hashEv = typeof HashChangeEvent === 'function'
|
||||||
|
? new HashChangeEvent('hashchange', { oldURL: '', newURL: location.href })
|
||||||
|
: new Event('hashchange');
|
||||||
|
window.dispatchEvent(hashEv);
|
||||||
|
} catch (__) {}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (typeof data.hash === 'string' && location.hash !== data.hash) {
|
||||||
|
try { location.hash = data.hash; } catch (__) {}
|
||||||
|
}
|
||||||
|
if ('state' in data) {
|
||||||
|
try { history.replaceState(data.state, '', location.href); } catch (__) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post();
|
||||||
|
}
|
||||||
|
function patch(name){
|
||||||
|
var original = history[name];
|
||||||
|
if (typeof original !== 'function') return;
|
||||||
|
history[name] = function(){
|
||||||
|
var result = original.apply(this, arguments);
|
||||||
|
post();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
patch('pushState');
|
||||||
|
patch('replaceState');
|
||||||
|
window.addEventListener('hashchange', post);
|
||||||
|
window.addEventListener('popstate', post);
|
||||||
|
window.addEventListener('message', function(ev){
|
||||||
|
var data = ev && ev.data;
|
||||||
|
if (!data) return;
|
||||||
|
if (data.type === 'od:preview-navigation-request') {
|
||||||
|
post(typeof data.requestId === 'string' ? data.requestId : undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === 'od:preview-navigation-restore') {
|
||||||
|
restore(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', post, { once: true });
|
||||||
|
else setTimeout(post, 0);
|
||||||
|
})();</script>`;
|
||||||
|
if (/<head[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<head[^>]*>/i, (m) => `${m}${script}`);
|
||||||
|
}
|
||||||
|
if (/<body[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<body[^>]*>/i, (m) => `${m}${script}`);
|
||||||
|
}
|
||||||
|
return script + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSrcdocTransportShell(): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script data-od-srcdoc-transport-shell>(function(){
|
||||||
|
function safePath(value){
|
||||||
|
return typeof value === 'string' && value.charAt(0) === '/' ? value : location.pathname;
|
||||||
|
}
|
||||||
|
function safeSearch(value){
|
||||||
|
if (typeof value !== 'string' || value.length === 0) return '';
|
||||||
|
return value.charAt(0) === '?' ? value : '?' + value;
|
||||||
|
}
|
||||||
|
function safeHash(value){
|
||||||
|
if (typeof value !== 'string' || value.length === 0) return '';
|
||||||
|
return value.charAt(0) === '#' ? value : '#' + value;
|
||||||
|
}
|
||||||
|
function restoreNavigation(navigation){
|
||||||
|
if (!navigation || typeof navigation !== 'object') return;
|
||||||
|
var prevHash = location.hash;
|
||||||
|
var nextUrl = safePath(navigation.pathname) + safeSearch(navigation.search) + safeHash(navigation.hash);
|
||||||
|
try {
|
||||||
|
history.replaceState('state' in navigation ? navigation.state : history.state, '', nextUrl);
|
||||||
|
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (__) {}
|
||||||
|
if (location.hash !== prevHash) {
|
||||||
|
try {
|
||||||
|
var hashEv = typeof HashChangeEvent === 'function'
|
||||||
|
? new HashChangeEvent('hashchange', { oldURL: '', newURL: location.href })
|
||||||
|
: new Event('hashchange');
|
||||||
|
window.dispatchEvent(hashEv);
|
||||||
|
} catch (__) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_) {
|
||||||
|
var hash = safeHash(navigation.hash);
|
||||||
|
if (hash && location.hash !== hash) {
|
||||||
|
try { location.hash = hash; } catch (__) {}
|
||||||
|
}
|
||||||
|
if ('state' in navigation) {
|
||||||
|
try { history.replaceState(navigation.state, '', location.href); } catch (__) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('message', function(ev){
|
||||||
|
var data = ev && ev.data;
|
||||||
|
if (!data || data.type !== 'od:srcdoc-transport-activate' || typeof data.html !== 'string') return;
|
||||||
|
restoreNavigation(data.navigation);
|
||||||
|
document.open();
|
||||||
|
document.write(data.html);
|
||||||
|
document.close();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({ type: 'od:srcdoc-transport-ready' }, '*');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();</script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
function projectDetailResolvedDir(
|
function projectDetailResolvedDir(
|
||||||
projectsRoot: string,
|
projectsRoot: string,
|
||||||
project: any,
|
project: any,
|
||||||
|
|
@ -1345,13 +1495,27 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
|
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
|
||||||
if (
|
const isHtmlFile = /^text\/html(?:;|$)/i.test(file.mime);
|
||||||
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
if (isHtmlFile && shouldInjectPreviewNavigationBridge(req.query.odSrcdocTransport)) {
|
||||||
/^text\/html(?:;|$)/i.test(file.mime)
|
res.type(file.mime).send(buildSrcdocTransportShell());
|
||||||
) {
|
|
||||||
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isHtmlFile) {
|
||||||
|
let html = file.buffer.toString('utf8');
|
||||||
|
let instrumented = false;
|
||||||
|
if (shouldInjectPreviewNavigationBridge(req.query.odPreviewNav)) {
|
||||||
|
html = injectPreviewNavigationBridge(html);
|
||||||
|
instrumented = true;
|
||||||
|
}
|
||||||
|
if (wantsUrlPreviewScrollBridge(req.query.odPreviewBridge)) {
|
||||||
|
html = injectUrlPreviewScrollBridge(html);
|
||||||
|
instrumented = true;
|
||||||
|
}
|
||||||
|
if (instrumented) {
|
||||||
|
res.type(file.mime).send(html);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
res.type(file.mime).send(file.buffer);
|
res.type(file.mime).send(file.buffer);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,34 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
|
||||||
expect(text).toBe('<html/>');
|
expect(text).toBe('<html/>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('injects the preview navigation reporter only when requested for HTML previews', async () => {
|
||||||
|
const raw = await fetch(rawUrl('page.html'));
|
||||||
|
expect(await raw.text()).toBe('<html/>');
|
||||||
|
|
||||||
|
const instrumented = await fetch(`${rawUrl('page.html')}?odPreviewNav=1`);
|
||||||
|
expect(instrumented.status).toBe(200);
|
||||||
|
const text = await instrumented.text();
|
||||||
|
expect(text).toContain('data-od-preview-navigation-bridge');
|
||||||
|
expect(text).toContain("type: 'od:preview-navigation'");
|
||||||
|
expect(text).toContain("data.type === 'od:preview-navigation-request'");
|
||||||
|
expect(text).toContain('message.requestId = requestId');
|
||||||
|
expect(text).toContain("data.type === 'od:preview-navigation-restore'");
|
||||||
|
expect(text).toContain("window.dispatchEvent(new PopStateEvent('popstate'");
|
||||||
|
expect(text).toContain('new HashChangeEvent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a same-origin srcdoc transport shell for bridge previews', async () => {
|
||||||
|
const res = await fetch(`${rawUrl('page.html')}?odSrcdocTransport=1`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain('data-od-srcdoc-transport-shell');
|
||||||
|
expect(text).toContain("data.type !== 'od:srcdoc-transport-activate'");
|
||||||
|
expect(text).toContain("type: 'od:srcdoc-transport-ready'");
|
||||||
|
expect(text).toContain('history.replaceState');
|
||||||
|
expect(text).not.toContain('<html/>');
|
||||||
|
});
|
||||||
|
|
||||||
it('injects the URL preview scroll bridge only when requested', async () => {
|
it('injects the URL preview scroll bridge only when requested', async () => {
|
||||||
const plain = await fetch(rawUrl('page.html'));
|
const plain = await fetch(rawUrl('page.html'));
|
||||||
expect(await plain.text()).toBe('<html/>');
|
expect(await plain.text()).toBe('<html/>');
|
||||||
|
|
@ -257,6 +285,15 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
|
||||||
expect(html.match(/data-od-url-scroll-bridge/g)?.length).toBe(1);
|
expect(html.match(/data-od-url-scroll-bridge/g)?.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can inject preview navigation and scroll bridges together', async () => {
|
||||||
|
const res = await fetch(`${rawUrl('body.html')}?odPreviewNav=1&odPreviewBridge=scroll`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain('data-od-preview-navigation-bridge');
|
||||||
|
expect(html).toContain('data-od-url-scroll-bridge');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 404 for a missing file', async () => {
|
it('returns 404 for a missing file', async () => {
|
||||||
const res = await fetch(rawUrl('missing.mp4'));
|
const res = await fetch(rawUrl('missing.mp4'));
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ import {
|
||||||
} from '../runtime/exports';
|
} from '../runtime/exports';
|
||||||
import { buildReactComponentSrcdoc } from '../runtime/react-component';
|
import { buildReactComponentSrcdoc } from '../runtime/react-component';
|
||||||
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
|
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
|
||||||
import { buildLazySrcdocTransport, buildSrcdoc, canActivateSrcDocTransport } from '../runtime/srcdoc';
|
import { buildSrcdoc, canActivateSrcDocTransport, type SrcdocPreviewNavigation } from '../runtime/srcdoc';
|
||||||
import {
|
import {
|
||||||
hasUrlModeBridge,
|
hasUrlModeBridge,
|
||||||
htmlNeedsFocusGuard,
|
htmlNeedsFocusGuard,
|
||||||
|
|
@ -142,6 +142,14 @@ export type ManualEditPendingStyleSave = {
|
||||||
version: number;
|
version: number;
|
||||||
};
|
};
|
||||||
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
||||||
|
type PreviewNavigationState = SrcdocPreviewNavigation & {
|
||||||
|
capturedAt: number;
|
||||||
|
};
|
||||||
|
type PreviewNavigationCaptureRequest = {
|
||||||
|
id: string;
|
||||||
|
target: 'url' | 'srcdoc';
|
||||||
|
};
|
||||||
|
type PreviewNavigationTarget = 'active' | 'url' | 'srcdoc';
|
||||||
type PreviewCanvasSize = { width: number; height: number };
|
type PreviewCanvasSize = { width: number; height: number };
|
||||||
type CommentPreviewCanvasOptions = {
|
type CommentPreviewCanvasOptions = {
|
||||||
boardMode: boolean;
|
boardMode: boolean;
|
||||||
|
|
@ -4271,6 +4279,10 @@ function HtmlViewer({
|
||||||
canvasTop: 0,
|
canvasTop: 0,
|
||||||
});
|
});
|
||||||
const previewScrollRequestAtRef = useRef(0);
|
const previewScrollRequestAtRef = useRef(0);
|
||||||
|
const previewNavigationRef = useRef<PreviewNavigationState | null>(null);
|
||||||
|
const previewNavigationRestoreRef = useRef<PreviewNavigationState | null>(null);
|
||||||
|
const previewNavigationRequestSeqRef = useRef(0);
|
||||||
|
const previewNavigationCaptureRequestRef = useRef<PreviewNavigationCaptureRequest | null>(null);
|
||||||
const dcViewportRef = useRef({
|
const dcViewportRef = useRef({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|
@ -4405,6 +4417,63 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
() => (typeof window === 'undefined' ? false : parseForceInline(window.location.search)),
|
() => (typeof window === 'undefined' ? false : parseForceInline(window.location.search)),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const requestPreviewNavigationState = useCallback(() => {
|
||||||
|
const frame = iframeRef.current;
|
||||||
|
const source = frame?.contentWindow;
|
||||||
|
const target =
|
||||||
|
frame === urlPreviewIframeRef.current
|
||||||
|
? 'url'
|
||||||
|
: frame === srcDocPreviewIframeRef.current
|
||||||
|
? 'srcdoc'
|
||||||
|
: null;
|
||||||
|
if (!source) {
|
||||||
|
previewNavigationCaptureRequestRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
previewNavigationCaptureRequestRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewNavigationRequestSeqRef.current += 1;
|
||||||
|
const requestId = `nav-${previewNavigationRequestSeqRef.current}`;
|
||||||
|
previewNavigationCaptureRequestRef.current = { id: requestId, target };
|
||||||
|
try {
|
||||||
|
source.postMessage({ type: 'od:preview-navigation-request', requestId }, '*');
|
||||||
|
} catch {
|
||||||
|
if (previewNavigationCaptureRequestRef.current?.id === requestId) {
|
||||||
|
previewNavigationCaptureRequestRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const capturePreviewNavigationState = useCallback(() => {
|
||||||
|
requestPreviewNavigationState();
|
||||||
|
previewNavigationRestoreRef.current = previewNavigationRef.current;
|
||||||
|
}, [requestPreviewNavigationState]);
|
||||||
|
const restorePreviewNavigationState = useCallback((navigation: PreviewNavigationState | null) => {
|
||||||
|
if (!navigation) return;
|
||||||
|
const post = (target: PreviewNavigationTarget = 'active') => {
|
||||||
|
if (previewNavigationRestoreRef.current !== navigation) return;
|
||||||
|
const frame =
|
||||||
|
target === 'url'
|
||||||
|
? urlPreviewIframeRef.current
|
||||||
|
: target === 'srcdoc'
|
||||||
|
? srcDocPreviewIframeRef.current
|
||||||
|
: iframeRef.current;
|
||||||
|
if (!frame?.contentWindow) return;
|
||||||
|
if (target === 'active') {
|
||||||
|
const active = frame.getAttribute('data-od-active') === 'true' || iframeRef.current === frame;
|
||||||
|
if (!active) return;
|
||||||
|
}
|
||||||
|
frame.contentWindow.postMessage({
|
||||||
|
type: 'od:preview-navigation-restore',
|
||||||
|
...navigation,
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
post();
|
||||||
|
window.setTimeout(post, 80);
|
||||||
|
window.setTimeout(post, 260);
|
||||||
|
return post;
|
||||||
|
}, []);
|
||||||
const [activeCommentTarget, setActiveCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
|
const [activeCommentTarget, setActiveCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
|
||||||
const [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
|
const [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
|
||||||
const [hoveredPodMemberId, setHoveredPodMemberId] = useState<string | null>(null);
|
const [hoveredPodMemberId, setHoveredPodMemberId] = useState<string | null>(null);
|
||||||
|
|
@ -4721,7 +4790,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
needsFocusGuard,
|
needsFocusGuard,
|
||||||
});
|
});
|
||||||
const basePreviewSrcUrl = useMemo(
|
const basePreviewSrcUrl = useMemo(
|
||||||
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll`,
|
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewNav=1&odPreviewBridge=scroll`,
|
||||||
|
[projectId, file.name, file.mtime, reloadKey],
|
||||||
|
);
|
||||||
|
const srcDocTransportSrcUrl = useMemo(
|
||||||
|
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odSrcdocTransport=1`,
|
||||||
[projectId, file.name, file.mtime, reloadKey],
|
[projectId, file.name, file.mtime, reloadKey],
|
||||||
);
|
);
|
||||||
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
|
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
|
||||||
|
|
@ -4733,10 +4806,33 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
: basePreviewSrcUrl;
|
: basePreviewSrcUrl;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPreviewSrcUrl(basePreviewSrcUrl);
|
setPreviewSrcUrl(basePreviewSrcUrl);
|
||||||
|
previewNavigationRef.current = null;
|
||||||
|
previewNavigationRestoreRef.current = null;
|
||||||
|
previewNavigationCaptureRequestRef.current = null;
|
||||||
}, [basePreviewSrcUrl]);
|
}, [basePreviewSrcUrl]);
|
||||||
|
const useUrlLoadPreviewCurrentRef = useRef(useUrlLoadPreview);
|
||||||
|
useUrlLoadPreviewCurrentRef.current = useUrlLoadPreview;
|
||||||
|
const alignActivePreviewIframeRef = useCallback(() => {
|
||||||
|
iframeRef.current = useUrlLoadPreviewCurrentRef.current ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current;
|
||||||
|
}, []);
|
||||||
|
const setUrlPreviewIframeRef = useCallback((frame: HTMLIFrameElement | null) => {
|
||||||
|
urlPreviewIframeRef.current = frame;
|
||||||
|
alignActivePreviewIframeRef();
|
||||||
|
}, [alignActivePreviewIframeRef]);
|
||||||
|
const setSrcDocPreviewIframeRef = useCallback((frame: HTMLIFrameElement | null) => {
|
||||||
|
srcDocPreviewIframeRef.current = frame;
|
||||||
|
alignActivePreviewIframeRef();
|
||||||
|
}, [alignActivePreviewIframeRef]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
iframeRef.current = useUrlLoadPreview ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current;
|
alignActivePreviewIframeRef();
|
||||||
}, [useUrlLoadPreview]);
|
}, [alignActivePreviewIframeRef, useUrlLoadPreview]);
|
||||||
|
useEffect(() => {
|
||||||
|
const navigation = previewNavigationRef.current;
|
||||||
|
if (!navigation) return;
|
||||||
|
previewNavigationRestoreRef.current = navigation;
|
||||||
|
const post = restorePreviewNavigationState(navigation);
|
||||||
|
post?.(useUrlLoadPreview ? 'url' : 'srcdoc');
|
||||||
|
}, [restorePreviewNavigationState, useUrlLoadPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesRefreshKey === 0) return;
|
if (filesRefreshKey === 0) return;
|
||||||
|
|
@ -4769,24 +4865,38 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
deck: effectiveDeck,
|
deck: effectiveDeck,
|
||||||
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
|
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
|
||||||
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
|
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
|
||||||
|
initialNavigation: previewNavigationRestoreRef.current,
|
||||||
selectionBridge: true,
|
selectionBridge: true,
|
||||||
editBridge: manualEditMode,
|
editBridge: manualEditMode,
|
||||||
paletteBridge: false,
|
paletteBridge: false,
|
||||||
previewFocusGuard: true,
|
previewFocusGuard: true,
|
||||||
}) : ''),
|
}) : ''),
|
||||||
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, manualEditMode],
|
[
|
||||||
|
previewSource,
|
||||||
|
effectiveDeck,
|
||||||
|
projectId,
|
||||||
|
file.name,
|
||||||
|
previewStateKey,
|
||||||
|
previewNavigationRestoreRef.current?.capturedAt,
|
||||||
|
manualEditMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const lazySrcDocTransport = useMemo(() => buildLazySrcdocTransport(), []);
|
|
||||||
const [srcDocTransportResetKey, setSrcDocTransportResetKey] = useState(0);
|
const [srcDocTransportResetKey, setSrcDocTransportResetKey] = useState(0);
|
||||||
const [srcDocShellReady, setSrcDocShellReady] = useState(false);
|
const srcDocShellInstanceKey = `${srcDocTransportResetKey}:${srcDocTransportSrcUrl}`;
|
||||||
|
const [srcDocShellReadyKey, setSrcDocShellReadyKey] = useState<string | null>(null);
|
||||||
|
const srcDocShellReady = srcDocShellReadyKey === srcDocShellInstanceKey;
|
||||||
const wasUrlLoadPreviewRef = useRef(useUrlLoadPreview);
|
const wasUrlLoadPreviewRef = useRef(useUrlLoadPreview);
|
||||||
|
const [hasLazySrcDocTransport, setHasLazySrcDocTransport] = useState(useUrlLoadPreview);
|
||||||
const urlPreviewKeepAliveKey = previewIframeKeepAliveKey(projectId, file.name);
|
const urlPreviewKeepAliveKey = previewIframeKeepAliveKey(projectId, file.name);
|
||||||
// Reset the shell-ready latch whenever the srcDoc iframe re-mounts. The
|
|
||||||
// next shell will post `od:srcdoc-transport-ready` (or fire onLoad) and
|
|
||||||
// flip this back to true. See #2253.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSrcDocShellReady(false);
|
if (useUrlLoadPreview) setHasLazySrcDocTransport(true);
|
||||||
}, [srcDocTransportResetKey]);
|
}, [useUrlLoadPreview]);
|
||||||
|
useEffect(() => {
|
||||||
|
activatedSrcDocTransportHtmlRef.current = null;
|
||||||
|
}, [srcDocTransportSrcUrl]);
|
||||||
|
// Key shell readiness to both the daemon shell URL and forced remount key.
|
||||||
|
// When either changes, srcDocShellReady falls false until the current shell
|
||||||
|
// posts `od:srcdoc-transport-ready` or fires onLoad. See #2253.
|
||||||
// Listen for the shell's ready handshake. Gating activation on this is
|
// Listen for the shell's ready handshake. Gating activation on this is
|
||||||
// what fixes the #2253 race: opening Tweaks right after a key-driven
|
// what fixes the #2253 race: opening Tweaks right after a key-driven
|
||||||
// re-mount used to post `activate` before the shell's listener was
|
// re-mount used to post `activate` before the shell's listener was
|
||||||
|
|
@ -4797,19 +4907,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
if (ev.source !== srcDocPreviewIframeRef.current?.contentWindow) return;
|
if (ev.source !== srcDocPreviewIframeRef.current?.contentWindow) return;
|
||||||
const data = ev.data as { type?: string } | null;
|
const data = ev.data as { type?: string } | null;
|
||||||
if (data?.type !== 'od:srcdoc-transport-ready') return;
|
if (data?.type !== 'od:srcdoc-transport-ready') return;
|
||||||
setSrcDocShellReady(true);
|
setSrcDocShellReadyKey(srcDocShellInstanceKey);
|
||||||
}
|
}
|
||||||
window.addEventListener('message', onMessage);
|
window.addEventListener('message', onMessage);
|
||||||
return () => window.removeEventListener('message', onMessage);
|
return () => window.removeEventListener('message', onMessage);
|
||||||
}, []);
|
}, [srcDocShellInstanceKey]);
|
||||||
// Lazy transport preloads an empty shell only while URL-load is the active
|
const useLazySrcDocTransport = useUrlLoadPreview || hasLazySrcDocTransport;
|
||||||
// transport. Once srcdoc becomes active (sandbox shim, Draw, Screenshot,
|
|
||||||
// Tweaks, etc.), mount the real artifact HTML directly so we do not depend on
|
|
||||||
// a postMessage activation that can race (#2253) and strand the iframe blank
|
|
||||||
// (#2361, #2791).
|
|
||||||
const captureModeActive = drawOverlayOpen;
|
|
||||||
const useLazySrcDocTransport = !manualEditMode && !captureModeActive && useUrlLoadPreview;
|
|
||||||
const srcDocTransportContent = useLazySrcDocTransport ? lazySrcDocTransport : srcDoc;
|
|
||||||
const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank';
|
const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank';
|
||||||
const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
|
const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
|
||||||
if (!canActivateSrcDocTransport({
|
if (!canActivateSrcDocTransport({
|
||||||
|
|
@ -4839,7 +4942,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
}
|
}
|
||||||
const win = target?.contentWindow;
|
const win = target?.contentWindow;
|
||||||
if (!win) return false;
|
if (!win) return false;
|
||||||
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*');
|
win.postMessage({
|
||||||
|
type: 'od:srcdoc-transport-activate',
|
||||||
|
html: srcDoc,
|
||||||
|
navigation: previewNavigationRestoreRef.current,
|
||||||
|
}, '*');
|
||||||
activatedSrcDocTransportHtmlRef.current = srcDoc;
|
activatedSrcDocTransportHtmlRef.current = srcDoc;
|
||||||
return true;
|
return true;
|
||||||
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview, srcDocShellReady, boardMode]);
|
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview, srcDocShellReady, boardMode]);
|
||||||
|
|
@ -4853,7 +4960,11 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
})) return false;
|
})) return false;
|
||||||
const win = target?.contentWindow;
|
const win = target?.contentWindow;
|
||||||
if (!win) return false;
|
if (!win) return false;
|
||||||
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*');
|
win.postMessage({
|
||||||
|
type: 'od:srcdoc-transport-activate',
|
||||||
|
html: srcDoc,
|
||||||
|
navigation: previewNavigationRestoreRef.current,
|
||||||
|
}, '*');
|
||||||
activatedSrcDocTransportHtmlRef.current = srcDoc;
|
activatedSrcDocTransportHtmlRef.current = srcDoc;
|
||||||
return true;
|
return true;
|
||||||
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview]);
|
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview]);
|
||||||
|
|
@ -4880,12 +4991,10 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
wasUrlLoadPreviewRef.current = false;
|
wasUrlLoadPreviewRef.current = false;
|
||||||
activateSrcDocTransport();
|
activateSrcDocTransport();
|
||||||
}, [activateSrcDocTransport, useUrlLoadPreview]);
|
}, [activateSrcDocTransport, useUrlLoadPreview]);
|
||||||
|
// Leaving Manual Edit can reuse a shell that just rendered edit HTML.
|
||||||
// Leaving Manual Edit swaps the iframe from a fully materialized srcDoc
|
// Remount before the next activation; otherwise posting into the old
|
||||||
// document back to the lazy transport shell. Remount the shell before
|
// document can mark the new HTML as activated before React replaces the
|
||||||
// activation; posting into the old edit document can mark the new HTML as
|
// iframe, causing the dedupe check to suppress the real activation.
|
||||||
// activated, then React replaces the iframe with an empty shell and the
|
|
||||||
// dedupe check suppresses the real activation.
|
|
||||||
const prevManualEditModeRef = useRef(manualEditMode);
|
const prevManualEditModeRef = useRef(manualEditMode);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasInEditMode = prevManualEditModeRef.current;
|
const wasInEditMode = prevManualEditModeRef.current;
|
||||||
|
|
@ -4894,11 +5003,10 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
|
|
||||||
if (wasInEditMode && !isNowInEditMode && !useUrlLoadPreview) {
|
if (wasInEditMode && !isNowInEditMode && !useUrlLoadPreview) {
|
||||||
activatedSrcDocTransportHtmlRef.current = null;
|
activatedSrcDocTransportHtmlRef.current = null;
|
||||||
setSrcDocShellReady(false);
|
|
||||||
setSrcDocTransportResetKey((key) => key + 1);
|
setSrcDocTransportResetKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
}, [manualEditMode, useUrlLoadPreview]);
|
}, [manualEditMode, useUrlLoadPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restorePreviewScrollPosition();
|
restorePreviewScrollPosition();
|
||||||
}, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]);
|
}, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]);
|
||||||
|
|
@ -4930,6 +5038,55 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
canvasTop: Number(data.canvasTop || 0),
|
canvasTop: Number(data.canvasTop || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function onNavigationMessage(ev: MessageEvent) {
|
||||||
|
if (!isOurPreviewIframeSource(ev.source)) return;
|
||||||
|
const data = ev.data as {
|
||||||
|
type?: string;
|
||||||
|
href?: unknown;
|
||||||
|
pathname?: unknown;
|
||||||
|
search?: unknown;
|
||||||
|
hash?: unknown;
|
||||||
|
state?: unknown;
|
||||||
|
requestId?: unknown;
|
||||||
|
} | null;
|
||||||
|
if (!data || data.type !== 'od:preview-navigation') return;
|
||||||
|
const isActiveSource = isActivePreviewIframeSource(ev.source);
|
||||||
|
const pendingRequest = previewNavigationCaptureRequestRef.current;
|
||||||
|
const requestId = typeof data.requestId === 'string' ? data.requestId : '';
|
||||||
|
const isPendingRequestSource =
|
||||||
|
pendingRequest?.target === 'url'
|
||||||
|
? ev.source === urlPreviewIframeRef.current?.contentWindow
|
||||||
|
: pendingRequest?.target === 'srcdoc'
|
||||||
|
? ev.source === srcDocPreviewIframeRef.current?.contentWindow
|
||||||
|
: false;
|
||||||
|
const matchesPendingRequest = !!(
|
||||||
|
requestId &&
|
||||||
|
pendingRequest &&
|
||||||
|
pendingRequest.id === requestId &&
|
||||||
|
isPendingRequestSource
|
||||||
|
);
|
||||||
|
const isPendingCaptureReply = !!(
|
||||||
|
!isActiveSource &&
|
||||||
|
matchesPendingRequest
|
||||||
|
);
|
||||||
|
if (!isActiveSource && !isPendingCaptureReply) return;
|
||||||
|
const navigation = {
|
||||||
|
href: typeof data.href === 'string' ? data.href : '',
|
||||||
|
pathname: typeof data.pathname === 'string' ? data.pathname : '',
|
||||||
|
search: typeof data.search === 'string' ? data.search : '',
|
||||||
|
hash: typeof data.hash === 'string' ? data.hash : '',
|
||||||
|
state: data.state,
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
};
|
||||||
|
if (matchesPendingRequest || (isActiveSource && pendingRequest)) {
|
||||||
|
previewNavigationCaptureRequestRef.current = null;
|
||||||
|
}
|
||||||
|
previewNavigationRef.current = navigation;
|
||||||
|
previewNavigationRestoreRef.current = navigation;
|
||||||
|
if (!isActiveSource) {
|
||||||
|
restorePreviewNavigationState(navigation);
|
||||||
|
}
|
||||||
|
}
|
||||||
function onRestoreRequest(ev: MessageEvent) {
|
function onRestoreRequest(ev: MessageEvent) {
|
||||||
if (!isOurPreviewIframeSource(ev.source)) return;
|
if (!isOurPreviewIframeSource(ev.source)) return;
|
||||||
if (!isActivePreviewIframeSource(ev.source)) return;
|
if (!isActivePreviewIframeSource(ev.source)) return;
|
||||||
|
|
@ -4984,14 +5141,16 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('message', onMessage);
|
window.addEventListener('message', onMessage);
|
||||||
|
window.addEventListener('message', onNavigationMessage);
|
||||||
window.addEventListener('message', onRestoreRequest);
|
window.addEventListener('message', onRestoreRequest);
|
||||||
window.addEventListener('message', onDcViewportMessage);
|
window.addEventListener('message', onDcViewportMessage);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('message', onMessage);
|
window.removeEventListener('message', onMessage);
|
||||||
|
window.removeEventListener('message', onNavigationMessage);
|
||||||
window.removeEventListener('message', onRestoreRequest);
|
window.removeEventListener('message', onRestoreRequest);
|
||||||
window.removeEventListener('message', onDcViewportMessage);
|
window.removeEventListener('message', onDcViewportMessage);
|
||||||
};
|
};
|
||||||
}, [isActivePreviewIframeSource, isOurPreviewIframeSource]);
|
}, [isActivePreviewIframeSource, isOurPreviewIframeSource, restorePreviewNavigationState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!effectiveDeck) {
|
if (!effectiveDeck) {
|
||||||
|
|
@ -5498,7 +5657,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
|
|
||||||
function refreshSrcDocPreviewAfterManualEditExit() {
|
function refreshSrcDocPreviewAfterManualEditExit() {
|
||||||
activatedSrcDocTransportHtmlRef.current = null;
|
activatedSrcDocTransportHtmlRef.current = null;
|
||||||
setSrcDocShellReady(false);
|
setSrcDocShellReadyKey(null);
|
||||||
setSrcDocTransportResetKey((key) => key + 1);
|
setSrcDocTransportResetKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6189,6 +6348,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateBoard(nextTool?: BoardTool) {
|
function activateBoard(nextTool?: BoardTool) {
|
||||||
|
capturePreviewNavigationState();
|
||||||
setMode('preview');
|
setMode('preview');
|
||||||
setBoardMode(true);
|
setBoardMode(true);
|
||||||
if (nextTool) setBoardTool(nextTool);
|
if (nextTool) setBoardTool(nextTool);
|
||||||
|
|
@ -6227,6 +6387,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
}
|
}
|
||||||
capturePreviewScrollPosition();
|
capturePreviewScrollPosition();
|
||||||
const activateDraw = () => {
|
const activateDraw = () => {
|
||||||
|
capturePreviewNavigationState();
|
||||||
setCommentPanelOpen(false);
|
setCommentPanelOpen(false);
|
||||||
setCommentCreateMode(false);
|
setCommentCreateMode(false);
|
||||||
setBoardMode(false);
|
setBoardMode(false);
|
||||||
|
|
@ -6248,6 +6409,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
function activateCommentTool() {
|
function activateCommentTool() {
|
||||||
fireArtifactToolbarClick('comment');
|
fireArtifactToolbarClick('comment');
|
||||||
capturePreviewScrollPosition();
|
capturePreviewScrollPosition();
|
||||||
|
capturePreviewNavigationState();
|
||||||
if (boardMode && !commentCreateMode && boardTool === 'inspect') {
|
if (boardMode && !commentCreateMode && boardTool === 'inspect') {
|
||||||
setBoardMode(false);
|
setBoardMode(false);
|
||||||
setCommentCreateMode(false);
|
setCommentCreateMode(false);
|
||||||
|
|
@ -6277,6 +6439,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
function activateCommentCreateTool() {
|
function activateCommentCreateTool() {
|
||||||
fireArtifactToolbarClick('comment');
|
fireArtifactToolbarClick('comment');
|
||||||
capturePreviewScrollPosition();
|
capturePreviewScrollPosition();
|
||||||
|
capturePreviewNavigationState();
|
||||||
if (boardMode && commentCreateMode) {
|
if (boardMode && commentCreateMode) {
|
||||||
setBoardMode(false);
|
setBoardMode(false);
|
||||||
setCommentCreateMode(false);
|
setCommentCreateMode(false);
|
||||||
|
|
@ -6308,6 +6471,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
function activateManualEditTool() {
|
function activateManualEditTool() {
|
||||||
fireArtifactToolbarClick('edit');
|
fireArtifactToolbarClick('edit');
|
||||||
capturePreviewScrollPosition();
|
capturePreviewScrollPosition();
|
||||||
|
capturePreviewNavigationState();
|
||||||
if (!manualEditMode) {
|
if (!manualEditMode) {
|
||||||
setCommentPanelOpen(false);
|
setCommentPanelOpen(false);
|
||||||
setCommentCreateMode(false);
|
setCommentCreateMode(false);
|
||||||
|
|
@ -7294,7 +7458,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
<div className="artifact-preview-transport-stack">
|
<div className="artifact-preview-transport-stack">
|
||||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||||
<PooledIframe
|
<PooledIframe
|
||||||
ref={urlPreviewIframeRef}
|
ref={setUrlPreviewIframeRef}
|
||||||
cacheKey={urlPreviewKeepAliveKey}
|
cacheKey={urlPreviewKeepAliveKey}
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||||
data-od-render-mode="url-load"
|
data-od-render-mode="url-load"
|
||||||
|
|
@ -7313,12 +7477,20 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
...dcViewportRef.current,
|
...dcViewportRef.current,
|
||||||
}, '*');
|
}, '*');
|
||||||
syncBridgeModes(frame);
|
syncBridgeModes(frame);
|
||||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
if (useUrlLoadPreview) {
|
||||||
|
restorePreviewScrollPosition();
|
||||||
|
const navigation = previewNavigationRef.current;
|
||||||
|
if (navigation) {
|
||||||
|
previewNavigationRestoreRef.current = navigation;
|
||||||
|
const post = restorePreviewNavigationState(navigation);
|
||||||
|
post?.('url');
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
ref={urlPreviewIframeRef}
|
ref={setUrlPreviewIframeRef}
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||||
data-od-render-mode="url-load"
|
data-od-render-mode="url-load"
|
||||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||||
|
|
@ -7336,13 +7508,21 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
...dcViewportRef.current,
|
...dcViewportRef.current,
|
||||||
}, '*');
|
}, '*');
|
||||||
syncBridgeModes(frame);
|
syncBridgeModes(frame);
|
||||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
if (useUrlLoadPreview) {
|
||||||
|
restorePreviewScrollPosition();
|
||||||
|
const navigation = previewNavigationRef.current;
|
||||||
|
if (navigation) {
|
||||||
|
previewNavigationRestoreRef.current = navigation;
|
||||||
|
const post = restorePreviewNavigationState(navigation);
|
||||||
|
post?.('url');
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<iframe
|
<iframe
|
||||||
key={srcDocTransportResetKey}
|
key={srcDocTransportResetKey}
|
||||||
ref={srcDocPreviewIframeRef}
|
ref={setSrcDocPreviewIframeRef}
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||||
data-od-render-mode="srcdoc"
|
data-od-render-mode="srcdoc"
|
||||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||||
|
|
@ -7350,7 +7530,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||||
title={file.name}
|
title={file.name}
|
||||||
sandbox="allow-scripts allow-downloads"
|
sandbox="allow-scripts allow-downloads"
|
||||||
srcDoc={srcDocTransportContent}
|
src={useLazySrcDocTransport ? srcDocTransportSrcUrl : undefined}
|
||||||
|
srcDoc={useLazySrcDocTransport ? undefined : srcDoc}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
const frame = srcDocPreviewIframeRef.current;
|
const frame = srcDocPreviewIframeRef.current;
|
||||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||||
|
|
@ -7388,7 +7569,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
srcDocFrameDedupeResetForRef.current = frame;
|
srcDocFrameDedupeResetForRef.current = frame;
|
||||||
activatedSrcDocTransportHtmlRef.current = null;
|
activatedSrcDocTransportHtmlRef.current = null;
|
||||||
}
|
}
|
||||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
if (useLazySrcDocTransport) setSrcDocShellReadyKey(srcDocShellInstanceKey);
|
||||||
activateLoadedSrcDocTransport(frame);
|
activateLoadedSrcDocTransport(frame);
|
||||||
dcViewportRestoreAtRef.current = Date.now();
|
dcViewportRestoreAtRef.current = Date.now();
|
||||||
frame?.contentWindow?.postMessage({
|
frame?.contentWindow?.postMessage({
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export type SrcdocOptions = {
|
||||||
deck?: boolean;
|
deck?: boolean;
|
||||||
baseHref?: string;
|
baseHref?: string;
|
||||||
initialSlideIndex?: number;
|
initialSlideIndex?: number;
|
||||||
|
initialNavigation?: SrcdocPreviewNavigation | null;
|
||||||
commentBridge?: boolean;
|
commentBridge?: boolean;
|
||||||
inspectBridge?: boolean;
|
inspectBridge?: boolean;
|
||||||
selectionBridge?: boolean;
|
selectionBridge?: boolean;
|
||||||
|
|
@ -34,6 +35,14 @@ export type SrcdocOptions = {
|
||||||
previewFocusGuard?: boolean;
|
previewFocusGuard?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SrcdocPreviewNavigation = {
|
||||||
|
href?: string;
|
||||||
|
pathname?: string;
|
||||||
|
search?: string;
|
||||||
|
hash?: string;
|
||||||
|
state?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export function buildSrcdoc(
|
export function buildSrcdoc(
|
||||||
html: string,
|
html: string,
|
||||||
options: SrcdocOptions = {}
|
options: SrcdocOptions = {}
|
||||||
|
|
@ -55,7 +64,8 @@ export function buildSrcdoc(
|
||||||
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
|
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
|
||||||
const withShim = injectSandboxShim(withBase);
|
const withShim = injectSandboxShim(withBase);
|
||||||
const withFocusGuard = options.previewFocusGuard ? injectPreviewFocusGuard(withShim) : withShim;
|
const withFocusGuard = options.previewFocusGuard ? injectPreviewFocusGuard(withShim) : withShim;
|
||||||
const withDeck = options.deck ? injectDeckBridge(withFocusGuard, options.initialSlideIndex) : withFocusGuard;
|
const withNavigation = injectPreviewNavigationRestore(withFocusGuard, options.initialNavigation ?? null);
|
||||||
|
const withDeck = options.deck ? injectDeckBridge(withNavigation, options.initialSlideIndex) : withNavigation;
|
||||||
// Comment + Inspect share an element-selection bridge: both pick a
|
// Comment + Inspect share an element-selection bridge: both pick a
|
||||||
// [data-od-id] / [data-screen-label] node and route the host's reply
|
// [data-od-id] / [data-screen-label] node and route the host's reply
|
||||||
// to either the comment popover (annotate) or the inspect panel
|
// to either the comment popover (annotate) or the inspect panel
|
||||||
|
|
@ -762,6 +772,172 @@ function injectPreviewFocusGuard(doc: string): string {
|
||||||
return script + doc;
|
return script + doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function injectPreviewNavigationRestore(
|
||||||
|
doc: string,
|
||||||
|
navigation: SrcdocPreviewNavigation | null,
|
||||||
|
): string {
|
||||||
|
const initialHash = normalizePreviewNavigationHash(navigation?.hash);
|
||||||
|
const initialPathname = normalizePreviewNavigationPath(navigation?.pathname);
|
||||||
|
const initialSearch = normalizePreviewNavigationSearch(navigation?.search);
|
||||||
|
const initialState = safePreviewNavigationState(navigation?.state);
|
||||||
|
const hasInitialNavigation = !!navigation && (
|
||||||
|
initialHash !== '' ||
|
||||||
|
initialPathname !== '' ||
|
||||||
|
initialSearch !== '' ||
|
||||||
|
Object.prototype.hasOwnProperty.call(navigation, 'state')
|
||||||
|
);
|
||||||
|
const script = `<script data-od-preview-navigation-restore>(function(){
|
||||||
|
var initialHash = ${jsonForInlineScript(initialHash)};
|
||||||
|
var initialPathname = ${jsonForInlineScript(initialPathname)};
|
||||||
|
var initialSearch = ${jsonForInlineScript(initialSearch)};
|
||||||
|
var initialState = ${jsonForInlineScript(initialState)};
|
||||||
|
var lastPostedFingerprint = null;
|
||||||
|
function snapshot(){
|
||||||
|
return location.pathname + location.search + location.hash;
|
||||||
|
}
|
||||||
|
function stateFingerprint(value){
|
||||||
|
try {
|
||||||
|
if (value === undefined) return 'u';
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (_) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function post(force, requestId){
|
||||||
|
try {
|
||||||
|
var href = snapshot();
|
||||||
|
var fingerprint = href + '\\n' + stateFingerprint(history.state);
|
||||||
|
if (force !== true && lastPostedFingerprint === fingerprint) return;
|
||||||
|
lastPostedFingerprint = fingerprint;
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
var message = {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: location.href,
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search,
|
||||||
|
hash: location.hash,
|
||||||
|
state: history.state
|
||||||
|
};
|
||||||
|
if (typeof requestId === 'string') message.requestId = requestId;
|
||||||
|
window.parent.postMessage(message, '*');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
function isAboutSrcdoc(){
|
||||||
|
try { return String(location.href || '') === 'about:srcdoc' || String(location.protocol || '') === 'about:'; }
|
||||||
|
catch (_) { return false; }
|
||||||
|
}
|
||||||
|
function restore(){
|
||||||
|
var prevHash = location.hash;
|
||||||
|
if (isAboutSrcdoc()) {
|
||||||
|
if (initialHash && location.hash !== initialHash) {
|
||||||
|
try { location.hash = initialHash; } catch (_) {}
|
||||||
|
}
|
||||||
|
post();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var nextPath = initialPathname || location.pathname;
|
||||||
|
var nextSearch = initialSearch || '';
|
||||||
|
var nextHash = initialHash || '';
|
||||||
|
var nextUrl = nextPath + nextSearch + nextHash;
|
||||||
|
if (initialState !== undefined) history.replaceState(initialState, '', nextUrl);
|
||||||
|
else history.replaceState(history.state, '', nextUrl);
|
||||||
|
try { window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } catch (_) {}
|
||||||
|
if (location.hash !== prevHash) {
|
||||||
|
try {
|
||||||
|
var ev = typeof HashChangeEvent === 'function'
|
||||||
|
? new HashChangeEvent('hashchange', { oldURL: '', newURL: location.href })
|
||||||
|
: new Event('hashchange');
|
||||||
|
window.dispatchEvent(ev);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (initialHash && location.hash !== initialHash) {
|
||||||
|
try { location.hash = initialHash; } catch (__) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post();
|
||||||
|
}
|
||||||
|
function patch(name){
|
||||||
|
var original = history[name];
|
||||||
|
if (typeof original !== 'function') return;
|
||||||
|
history[name] = function(){
|
||||||
|
var result = original.apply(this, arguments);
|
||||||
|
post();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
patch('pushState');
|
||||||
|
patch('replaceState');
|
||||||
|
window.addEventListener('hashchange', function(){ post(); });
|
||||||
|
window.addEventListener('popstate', function(){ post(); });
|
||||||
|
window.addEventListener('message', function(ev){
|
||||||
|
var data = ev && ev.data;
|
||||||
|
if (!data) return;
|
||||||
|
if (data.type === 'od:preview-navigation-request') {
|
||||||
|
post(true, typeof data.requestId === 'string' ? data.requestId : undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === 'od:preview-navigation-restore') {
|
||||||
|
if (typeof data.hash === 'string') initialHash = data.hash;
|
||||||
|
if (typeof data.pathname === 'string') initialPathname = data.pathname;
|
||||||
|
if (typeof data.search === 'string') initialSearch = data.search;
|
||||||
|
if ('state' in data) initialState = data.state;
|
||||||
|
restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (${hasInitialNavigation ? 'true' : 'false'}) restore();
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ post(); }, { once: true });
|
||||||
|
else setTimeout(function(){ post(); }, 0);
|
||||||
|
})();</script>`;
|
||||||
|
if (/<head[^>]*>/i.test(doc)) {
|
||||||
|
return doc.replace(/<head[^>]*>/i, (m) => `${m}${script}`);
|
||||||
|
}
|
||||||
|
if (/<body[^>]*>/i.test(doc)) {
|
||||||
|
return doc.replace(/<body[^>]*>/i, (m) => `${m}${script}`);
|
||||||
|
}
|
||||||
|
return script + doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreviewNavigationHash(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
if (value.length === 0) return '';
|
||||||
|
return value.startsWith('#') ? value : `#${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreviewNavigationPath(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
if (!value.startsWith('/')) return '';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreviewNavigationSearch(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
if (value.length === 0) return '';
|
||||||
|
return value.startsWith('?') ? value : `?${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePreviewNavigationState(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonForInlineScript(value: unknown): string {
|
||||||
|
if (value === undefined) return 'undefined';
|
||||||
|
return JSON.stringify(value)
|
||||||
|
.replace(/</g, '\\u003c')
|
||||||
|
.replace(/>/g, '\\u003e')
|
||||||
|
.replace(/&/g, '\\u0026')
|
||||||
|
.replace(/\u2028/g, '\\u2028')
|
||||||
|
.replace(/\u2029/g, '\\u2029');
|
||||||
|
}
|
||||||
|
|
||||||
// Selection bridge: shared substrate for Comment mode and Inspect mode.
|
// Selection bridge: shared substrate for Comment mode and Inspect mode.
|
||||||
// Both modes pick a [data-od-id] / [data-screen-label] element on click;
|
// Both modes pick a [data-od-id] / [data-screen-label] element on click;
|
||||||
// the difference is what the host does with the selection — annotate
|
// the difference is what the host does with the selection — annotate
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@ vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
|
||||||
|
|
||||||
import { FileViewer } from '../../src/components/FileViewer';
|
import { FileViewer } from '../../src/components/FileViewer';
|
||||||
|
|
||||||
|
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
|
||||||
|
return calls
|
||||||
|
.map(([message]) => message)
|
||||||
|
.filter((message): message is {
|
||||||
|
type: 'od:srcdoc-transport-activate';
|
||||||
|
html: string;
|
||||||
|
} => {
|
||||||
|
if (typeof message !== 'object' || message === null) return false;
|
||||||
|
const data = message as { type?: unknown; html?: unknown };
|
||||||
|
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openManualTools() {
|
function openManualTools() {
|
||||||
// Manual tools now live directly in the primary toolbar.
|
// Manual tools now live directly in the primary toolbar.
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +269,14 @@ describe('FileViewer manual edit history regressions', () => {
|
||||||
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||||
expect(panelState.props?.draft.fullSource).toContain('Hero');
|
expect(panelState.props?.draft.fullSource).toContain('Hero');
|
||||||
});
|
});
|
||||||
|
const srcDocFrame = getActivePreviewFrame();
|
||||||
|
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
|
||||||
|
fireEvent.load(srcDocFrame);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||||
|
expect(activatedHtml).toContain('Hero');
|
||||||
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
panelState.props?.onApplyPatch(
|
panelState.props?.onApplyPatch(
|
||||||
{ id: 'hero', kind: 'set-text', value: 'Updated hero' },
|
{ id: 'hero', kind: 'set-text', value: 'Updated hero' },
|
||||||
|
|
@ -266,7 +287,8 @@ describe('FileViewer manual edit history regressions', () => {
|
||||||
await waitFor(() => expect(savedSources).toHaveLength(1));
|
await waitFor(() => expect(savedSources).toHaveLength(1));
|
||||||
await waitFor(() => expect(panelState.props?.draft.fullSource).toContain('Updated hero'));
|
await waitFor(() => expect(panelState.props?.draft.fullSource).toContain('Updated hero'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getActivePreviewFrame().srcdoc).toContain('Updated hero');
|
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||||
|
expect(activatedHtml).toContain('Updated hero');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { useLayoutEffect, useRef, useState } from 'react';
|
import { useLayoutEffect, useRef, useState, type RefObject } from 'react';
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
@ -79,13 +79,30 @@ function deferredResponse() {
|
||||||
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
|
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
|
||||||
return calls
|
return calls
|
||||||
.map(([message]) => message)
|
.map(([message]) => message)
|
||||||
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
|
.filter((message): message is {
|
||||||
|
type: 'od:srcdoc-transport-activate';
|
||||||
|
html: string;
|
||||||
|
navigation?: unknown;
|
||||||
|
} => {
|
||||||
if (typeof message !== 'object' || message === null) return false;
|
if (typeof message !== 'object' || message === null) return false;
|
||||||
const data = message as { type?: unknown; html?: unknown };
|
const data = message as { type?: unknown; html?: unknown };
|
||||||
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
|
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewNavigationRequestId(calls: readonly (readonly unknown[])[]) {
|
||||||
|
const request = [...calls]
|
||||||
|
.reverse()
|
||||||
|
.map(([message]) => message)
|
||||||
|
.find((message): message is { type: 'od:preview-navigation-request'; requestId: string } => {
|
||||||
|
if (typeof message !== 'object' || message === null) return false;
|
||||||
|
const data = message as { type?: unknown; requestId?: unknown };
|
||||||
|
return data.type === 'od:preview-navigation-request' && typeof data.requestId === 'string';
|
||||||
|
});
|
||||||
|
if (!request) throw new Error('preview navigation request id not found');
|
||||||
|
return request.requestId;
|
||||||
|
}
|
||||||
|
|
||||||
function testRect(left: number, top: number, width: number, height: number): DOMRect {
|
function testRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||||
return {
|
return {
|
||||||
x: left,
|
x: left,
|
||||||
|
|
@ -523,7 +540,7 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
const { container } = render(<Shell />);
|
const { container } = render(<Shell />);
|
||||||
|
|
||||||
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||||
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Leave project' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Leave project' }));
|
||||||
|
|
||||||
|
|
@ -531,7 +548,7 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
expect(screen.getByTestId('home-view')).toBeTruthy();
|
expect(screen.getByTestId('home-view')).toBeTruthy();
|
||||||
const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe');
|
const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe');
|
||||||
expect(parkedFrame).toBe(firstFrame);
|
expect(parkedFrame).toBe(firstFrame);
|
||||||
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Return project' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Return project' }));
|
||||||
|
|
||||||
|
|
@ -678,7 +695,7 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
expect(markup).toContain('data-od-render-mode="url-load"');
|
expect(markup).toContain('data-od-render-mode="url-load"');
|
||||||
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
|
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
|
||||||
expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"');
|
expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"');
|
||||||
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&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"');
|
expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -746,20 +763,23 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
expect(srcDocFrame).toBeTruthy();
|
expect(srcDocFrame).toBeTruthy();
|
||||||
expect(urlFrame?.getAttribute('data-od-active')).toBe('true');
|
expect(urlFrame?.getAttribute('data-od-active')).toBe('true');
|
||||||
expect(srcDocFrame?.getAttribute('data-od-active')).toBe('false');
|
expect(srcDocFrame?.getAttribute('data-od-active')).toBe('false');
|
||||||
expect(srcDocFrame?.srcdoc).toContain('data-od-lazy-srcdoc-transport');
|
expect(srcDocFrame?.getAttribute('src')).toContain('odSrcdocTransport=1');
|
||||||
expect(srcDocFrame?.srcdoc).not.toContain('__odArtifactBootCount');
|
expect(srcDocFrame?.srcdoc).not.toContain('__odArtifactBootCount');
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
const urlFrameAfter = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
|
const urlFrameAfter = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
|
||||||
const srcDocFrameAfter = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;
|
const srcDocFrameAfter = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;
|
||||||
|
const srcDocPostMessageSpy = vi.spyOn(srcDocFrameAfter!.contentWindow!, 'postMessage');
|
||||||
|
fireEvent.load(srcDocFrameAfter!);
|
||||||
|
|
||||||
expect(urlFrameAfter).toBe(urlFrame);
|
expect(urlFrameAfter).toBe(urlFrame);
|
||||||
expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false');
|
expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false');
|
||||||
expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank');
|
expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank');
|
||||||
expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true');
|
expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true');
|
||||||
expect(srcDocFrameAfter?.srcdoc).toContain('__odArtifactBootCount');
|
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||||
expect(srcDocFrameAfter?.srcdoc).toContain('data-od-edit-bridge');
|
expect(activatedHtml).toContain('__odArtifactBootCount');
|
||||||
|
expect(activatedHtml).toContain('data-od-edit-bridge');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders sandbox-shim artifacts on the srcdoc transport without entering edit mode (#2791)', () => {
|
it('renders sandbox-shim artifacts on the srcdoc transport without entering edit mode (#2791)', () => {
|
||||||
|
|
@ -831,11 +851,563 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }));
|
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }));
|
||||||
|
|
||||||
|
const activeFrame = await waitFor(() => {
|
||||||
|
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||||
|
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||||
|
return frame;
|
||||||
|
});
|
||||||
|
const postMessageSpy = vi.spyOn(activeFrame.contentWindow!, 'postMessage');
|
||||||
|
fireEvent.load(activeFrame);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
const activatedHtml = srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||||
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
expect(activatedHtml).toContain('data-od-edit-bridge');
|
||||||
expect(activeFrame.srcdoc).toContain('data-od-edit-bridge');
|
expect(activatedHtml).toContain('Hero');
|
||||||
expect(activeFrame.srcdoc).toContain('Hero');
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores captured hash navigation when switching a URL-loaded app into bridge mode', async () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/settings',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html',
|
||||||
|
search: '?v=1710000000&r=0&odPreviewNav=1',
|
||||||
|
hash: '#/settings',
|
||||||
|
state: { tab: 'settings' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
const srcDocFrame = await waitFor(() => {
|
||||||
|
const activeFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
|
||||||
|
expect(activeFrame.getAttribute('data-od-active')).toBe('true');
|
||||||
|
expect(activeFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
|
||||||
|
return activeFrame;
|
||||||
|
});
|
||||||
|
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
|
||||||
|
fireEvent.load(srcDocFrame);
|
||||||
|
await waitFor(() => {
|
||||||
|
const activatedHtml = srcDocActivationMessages(srcDocPostMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||||
|
expect(activatedHtml).toContain('data-od-preview-navigation-restore');
|
||||||
|
expect(activatedHtml).toContain('initialHash = "#/settings"');
|
||||||
|
expect(activatedHtml).toContain('"tab":"settings"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the bridge iframe from the daemon srcdoc transport shell before restoring non-hash navigation', async () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1/dashboard?panel=metrics#daily',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html/dashboard',
|
||||||
|
search: '?panel=metrics',
|
||||||
|
hash: '#daily',
|
||||||
|
state: { panel: 'metrics' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(srcDocFrame.getAttribute('data-od-active')).toBe('true');
|
||||||
|
});
|
||||||
|
expect(srcDocFrame.getAttribute('src')).toContain('/api/projects/project-1/raw/page.html?');
|
||||||
|
expect(srcDocFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
|
||||||
|
expect(srcDocFrame.srcdoc).not.toContain('initialPathname = "/api/projects/project-1/raw/page.html/dashboard"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests and restores navigation when opening a bridge tool switches to bridge mode', async () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
const urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
expect(urlPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-request',
|
||||||
|
requestId: expect.any(String),
|
||||||
|
}), '*');
|
||||||
|
const requestId = previewNavigationRequestId(urlPostMessageSpy.mock.calls);
|
||||||
|
const srcDocFrame = await waitFor(() => {
|
||||||
|
const activeFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
|
||||||
|
expect(activeFrame.getAttribute('data-od-active')).toBe('true');
|
||||||
|
return activeFrame;
|
||||||
|
});
|
||||||
|
expect(srcDocFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
|
||||||
|
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1/dashboard?panel=metrics#daily',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html/dashboard',
|
||||||
|
search: '?panel=metrics',
|
||||||
|
hash: '#daily',
|
||||||
|
state: { panel: 'metrics' },
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(srcDocPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-restore',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html/dashboard',
|
||||||
|
search: '?panel=metrics',
|
||||||
|
hash: '#daily',
|
||||||
|
state: { panel: 'metrics' },
|
||||||
|
}), '*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a URL iframe navigation reply that arrives before passive ref alignment', () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let restoreCallsDuringHandoff = 0;
|
||||||
|
let urlPostMessageSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||||
|
let srcDocPostMessageSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||||
|
|
||||||
|
function EarlyNavigationReplyProbe({
|
||||||
|
active,
|
||||||
|
hostRef,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
hostRef: RefObject<HTMLDivElement | null>;
|
||||||
|
}) {
|
||||||
|
const sentRef = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!active || sentRef.current) return;
|
||||||
|
sentRef.current = true;
|
||||||
|
const root = hostRef.current;
|
||||||
|
const urlFrame = root?.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
|
||||||
|
const srcDocFrame = root?.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;
|
||||||
|
if (!urlFrame?.contentWindow || !srcDocFrame?.contentWindow) return;
|
||||||
|
srcDocPostMessageSpy ??= vi.spyOn(srcDocFrame.contentWindow, 'postMessage');
|
||||||
|
const requestId = urlPostMessageSpy ? previewNavigationRequestId(urlPostMessageSpy.mock.calls) : '';
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0/reports#q2',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html/reports',
|
||||||
|
search: '',
|
||||||
|
hash: '#q2',
|
||||||
|
state: { report: 'q2' },
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
restoreCallsDuringHandoff = srcDocPostMessageSpy.mock.calls.filter((call: unknown[]) => {
|
||||||
|
const message = call[0];
|
||||||
|
return (
|
||||||
|
typeof message === 'object' &&
|
||||||
|
message !== null &&
|
||||||
|
(message as { type?: unknown }).type === 'od:preview-navigation-restore'
|
||||||
|
);
|
||||||
|
}).length;
|
||||||
|
}, [active, hostRef]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const [replyDuringHandoff, setReplyDuringHandoff] = useState(false);
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
onClickCapture={(ev) => {
|
||||||
|
if ((ev.target as Element | null)?.closest('[data-testid="manual-edit-mode-toggle"]')) {
|
||||||
|
setReplyDuringHandoff(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>
|
||||||
|
<EarlyNavigationReplyProbe active={replyDuringHandoff} hostRef={hostRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(<Harness />);
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
expect(restoreCallsDuringHandoff).toBe(1);
|
||||||
|
expect(srcDocPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-restore',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html/reports',
|
||||||
|
hash: '#q2',
|
||||||
|
state: { report: 'q2' },
|
||||||
|
}), '*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not echo active preview navigation back into the same iframe', () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
const urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/settings',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html',
|
||||||
|
search: '?v=1710000000&r=0&odPreviewNav=1',
|
||||||
|
hash: '#/settings',
|
||||||
|
state: { tab: 'settings' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(urlPostMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-restore',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html',
|
||||||
|
search: '?v=1710000000&r=0&odPreviewNav=1',
|
||||||
|
hash: '#/settings',
|
||||||
|
state: { tab: 'settings' },
|
||||||
|
}), '*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale navigation reports from an inactive preview iframe', async () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
|
||||||
|
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/settings',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html',
|
||||||
|
search: '?v=1710000000&r=0&odPreviewNav=1',
|
||||||
|
hash: '#/settings',
|
||||||
|
state: { tab: 'settings' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(srcDocFrame.getAttribute('data-od-active')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: srcDocFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'about:srcdoc#/current',
|
||||||
|
pathname: '/',
|
||||||
|
search: '',
|
||||||
|
hash: '#/current',
|
||||||
|
state: { tab: 'current' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
srcDocPostMessageSpy.mockClear();
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/stale',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html',
|
||||||
|
search: '?v=1710000000&r=0&odPreviewNav=1',
|
||||||
|
hash: '#/stale',
|
||||||
|
state: { tab: 'stale' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(srcDocPostMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-restore',
|
||||||
|
hash: '#/stale',
|
||||||
|
state: { tab: 'stale' },
|
||||||
|
}), '*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a delayed capture reply after the active bridge iframe reports newer navigation', async () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
|
||||||
|
const urlPostMessageSpy = vi.spyOn(urlFrame.contentWindow!, 'postMessage');
|
||||||
|
const srcDocPostMessageSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
expect(urlPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-request',
|
||||||
|
requestId: expect.any(String),
|
||||||
|
}), '*');
|
||||||
|
const requestId = previewNavigationRequestId(urlPostMessageSpy.mock.calls);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(srcDocFrame.getAttribute('data-od-active')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: srcDocFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'about:srcdoc#/current',
|
||||||
|
pathname: '/',
|
||||||
|
search: '',
|
||||||
|
hash: '#/current',
|
||||||
|
state: { tab: 'current' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
srcDocPostMessageSpy.mockClear();
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: urlFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1#/stale-requested',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html',
|
||||||
|
search: '?v=1710000000&r=0&odPreviewNav=1',
|
||||||
|
hash: '#/stale-requested',
|
||||||
|
state: { tab: 'stale-requested' },
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(srcDocPostMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-restore',
|
||||||
|
hash: '#/stale-requested',
|
||||||
|
state: { tab: 'stale-requested' },
|
||||||
|
}), '*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores bridge navigation when returning to URL-loaded preview mode', async () => {
|
||||||
|
const file = baseFile({
|
||||||
|
name: 'page.html',
|
||||||
|
path: 'page.html',
|
||||||
|
mime: 'text/html',
|
||||||
|
kind: 'html',
|
||||||
|
artifactManifest: {
|
||||||
|
version: 1,
|
||||||
|
kind: 'html',
|
||||||
|
title: 'Page',
|
||||||
|
entry: 'page.html',
|
||||||
|
renderer: 'html',
|
||||||
|
exports: ['html'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={file}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
const srcDocFrame = await waitFor(() => {
|
||||||
|
const activeFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement;
|
||||||
|
expect(activeFrame.getAttribute('data-od-active')).toBe('true');
|
||||||
|
return activeFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new MessageEvent('message', {
|
||||||
|
source: srcDocFrame.contentWindow,
|
||||||
|
data: {
|
||||||
|
type: 'od:preview-navigation',
|
||||||
|
href: 'http://localhost/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewNav=1/editor?panel=layers#shape-3',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html/editor',
|
||||||
|
search: '?panel=layers',
|
||||||
|
hash: '#shape-3',
|
||||||
|
state: { panel: 'layers' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(urlFrame.getAttribute('data-od-active')).toBe('true');
|
||||||
|
});
|
||||||
|
const activeUrlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement;
|
||||||
|
const urlPostMessageSpy = vi.spyOn(activeUrlFrame.contentWindow!, 'postMessage');
|
||||||
|
urlPostMessageSpy.mockClear();
|
||||||
|
fireEvent.load(activeUrlFrame);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(urlPostMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'od:preview-navigation-restore',
|
||||||
|
pathname: '/api/projects/project-1/raw/page.html/editor',
|
||||||
|
search: '?panel=layers',
|
||||||
|
hash: '#shape-3',
|
||||||
|
state: { panel: 'layers' },
|
||||||
|
}), '*');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -889,7 +1461,7 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
const { container } = render(<Switcher />);
|
const { container } = render(<Switcher />);
|
||||||
const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
|
const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
|
||||||
const initialFrame = getFrame();
|
const initialFrame = getFrame();
|
||||||
expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
|
||||||
|
|
||||||
const observationsBeforeSwitch = observedCommittedSrcs.length;
|
const observationsBeforeSwitch = observedCommittedSrcs.length;
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
|
||||||
|
|
@ -897,9 +1469,9 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
const nextFrame = getFrame();
|
const nextFrame = getFrame();
|
||||||
expect(nextFrame).toBeTruthy();
|
expect(nextFrame).toBeTruthy();
|
||||||
expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe(
|
expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe(
|
||||||
'/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll',
|
'/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll',
|
||||||
);
|
);
|
||||||
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewNav=1&odPreviewBridge=scroll');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows downloads in the in-tab HTML presentation iframe', async () => {
|
it('allows downloads in the in-tab HTML presentation iframe', async () => {
|
||||||
|
|
@ -1870,7 +2442,7 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
expect(screen.queryByPlaceholderText('Add a note for this mark')).toBeNull();
|
expect(screen.queryByPlaceholderText('Add a note for this mark')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a materialized srcDoc bridge while the Draw bar is open', async () => {
|
it('activates the srcDoc transport bridge while the Draw bar is open', async () => {
|
||||||
render(
|
render(
|
||||||
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
|
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
|
||||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
|
@ -1883,18 +2455,20 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
const frame = await waitFor(() => {
|
const frame = await waitFor(() => {
|
||||||
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||||
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||||
expect(activeFrame.srcdoc).toContain('data-od-selection-bridge');
|
expect(activeFrame.getAttribute('src')).toContain('odSrcdocTransport=1');
|
||||||
expect(activeFrame.srcdoc).toContain('data-od-snapshot-bridge');
|
|
||||||
expect(activeFrame.srcdoc).not.toContain('data-od-lazy-srcdoc-transport');
|
|
||||||
return activeFrame;
|
return activeFrame;
|
||||||
});
|
});
|
||||||
|
const postMessageSpy = vi.spyOn(frame.contentWindow!, 'postMessage');
|
||||||
|
fireEvent.load(frame);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(frame.srcdoc).toContain('data-od-id="hero"');
|
const activatedHtml = srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html ?? '';
|
||||||
|
expect(activatedHtml).toContain('data-od-selection-bridge');
|
||||||
|
expect(activatedHtml).toContain('data-od-id="hero"');
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('button', { name: 'Click' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Click' })).toBeNull();
|
||||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();
|
||||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy();
|
||||||
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc);
|
expect(screen.getByTestId('artifact-preview-frame')).toBe(frame);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves URL-loaded preview scroll when opening Draw', async () => {
|
it('preserves URL-loaded preview scroll when opening Draw', async () => {
|
||||||
|
|
|
||||||
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
|
## Stagger
|
||||||
|
|
||||||
Offset the animation of each item by 0.1 second like this:
|
Offset the animation of each item by 0.1 second like this:
|
||||||
```javascript
|
```javascript
|
||||||
gsap.to(".item", {
|
gsap.to(".item", {
|
||||||
y: -20,
|
y: -20,
|
||||||
stagger: 0.1
|
stagger: 0.1
|
||||||
|
|
@ -157,7 +157,7 @@ base (out) .in .out .inOut
|
||||||
|
|
||||||
### Custom: use CustomEase (plugin)
|
### Custom: use CustomEase (plugin)
|
||||||
|
|
||||||
Simple cubic-bezier values (as used in CSS `cubic-bezier()`):
|
Simple cubic-bezier values (as used in CSS `cubic-bezier()`):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const myEase = CustomEase.create("my-ease", ".17,.67,.83,.67");
|
const myEase = CustomEase.create("my-ease", ".17,.67,.83,.67");
|
||||||
|
|
@ -165,7 +165,7 @@ const myEase = CustomEase.create("my-ease", ".17,.67,.83,.67");
|
||||||
gsap.to(".item", {x: 100, ease: myEase, duration: 1});
|
gsap.to(".item", {x: 100, ease: myEase, duration: 1});
|
||||||
```
|
```
|
||||||
|
|
||||||
Complex curve with any number of control points, described as normalized SVG path data:
|
Complex curve with any number of control points, described as normalized SVG path data:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const myEase = CustomEase.create("hop", "M0,0 C0,0 0.056,0.442 0.175,0.442 0.294,0.442 0.332,0 0.332,0 0.332,0 0.414,1 0.671,1 0.991,1 1,0 1,0");
|
const myEase = CustomEase.create("hop", "M0,0 C0,0 0.056,0.442 0.175,0.442 0.294,0.442 0.332,0 0.332,0 0.332,0 0.414,1 0.671,1 0.991,1 1,0 1,0");
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ GSAP batches updates internally. When mixing GSAP with direct DOM reads/writes o
|
||||||
|
|
||||||
## Frequently updated properties (e.g. mouse followers)
|
## Frequently updated properties (e.g. mouse followers)
|
||||||
|
|
||||||
Prefer **gsap.quickTo()** for properties that are updated often (e.g. mouse-follower x/y). It reuses a single tween instead of creating new tweens on each update.
|
Prefer **gsap.quickTo()** for properties that are updated often (e.g. mouse-follower x/y). It reuses a single tween instead of creating new tweens on each update.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
let xTo = gsap.quickTo("#id", "x", { duration: 0.4, ease: "power3" }),
|
let xTo = gsap.quickTo("#id", "x", { duration: 0.4, ease: "power3" }),
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ gsap.to(scrollContainer, { duration: 1, scrollTo: { x: "max" } });
|
||||||
|
|
||||||
### ScrollSmoother
|
### ScrollSmoother
|
||||||
|
|
||||||
Smooth scroll wrapper (smooths native scroll). Requires ScrollTrigger and a specific DOM structure (content wrapper + smooth wrapper). Use when smooth, momentum-style scroll is needed. See GSAP docs for setup; register after ScrollTrigger. DOM structure would look like:
|
Smooth scroll wrapper (smooths native scroll). Requires ScrollTrigger and a specific DOM structure (content wrapper + smooth wrapper). Use when smooth, momentum-style scroll is needed. See GSAP docs for setup; register after ScrollTrigger. DOM structure would look like:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -146,12 +146,12 @@ gsap.registerPlugin(Draggable, InertiaPlugin);
|
||||||
Draggable.create(".box", { type: "x,y", inertia: true });
|
Draggable.create(".box", { type: "x,y", inertia: true });
|
||||||
```
|
```
|
||||||
|
|
||||||
Or track velocity of a property:
|
Or track velocity of a property:
|
||||||
```javascript
|
```javascript
|
||||||
InertiaPlugin.track(".box", "x");
|
InertiaPlugin.track(".box", "x");
|
||||||
```
|
```
|
||||||
|
|
||||||
Then use `"auto"` to continue the current velocity and glide to a stop:
|
Then use `"auto"` to continue the current velocity and glide to a stop:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
gsap.to(obj, { inertia: { x: "auto" } });
|
gsap.to(obj, { inertia: { x: "auto" } });
|
||||||
|
|
@ -252,7 +252,7 @@ gsap.to(".text", {
|
||||||
|
|
||||||
Reveals or hides the stroke of SVG elements by animating `stroke-dashoffset` / `stroke-dasharray`. Works on `<path>`, `<line>`, `<polyline>`, `<polygon>`, `<rect>`, `<ellipse>`. Use when “drawing” or “erasing” strokes.
|
Reveals or hides the stroke of SVG elements by animating `stroke-dashoffset` / `stroke-dasharray`. Works on `<path>`, `<line>`, `<polyline>`, `<polygon>`, `<rect>`, `<ellipse>`. Use when “drawing” or “erasing” strokes.
|
||||||
|
|
||||||
**drawSVG value:** Describes the **visible segment** of the stroke along the path (start and end positions), not “animate from A to B over time.” Format: `"start end"` in percent or length. Examples: `"0% 100%"` = full stroke; `"20% 80%"` = stroke only between 20% and 80% (gaps at both ends). The tween animates from the 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.
|
**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
|
```javascript
|
||||||
useGSAP(() => {
|
useGSAP(() => {
|
||||||
// gsap code here, just like in a useEffect()
|
// gsap code here, just like in a useEffect()
|
||||||
},{
|
},{
|
||||||
dependencies: [endX], // dependency array (optional)
|
dependencies: [endX], // dependency array (optional)
|
||||||
scope: container, // scope selector text (optional, recommended)
|
scope: container, // scope selector text (optional, recommended)
|
||||||
revertOnUpdate: true // causes the context to be reverted and the cleanup function to run every time the hook re-synchronizes (when any dependency changes)
|
revertOnUpdate: true // causes the context to be reverted and the cleanup function to run every time the hook re-synchronizes (when any dependency changes)
|
||||||
|
|
|
||||||
|
|
@ -238,13 +238,13 @@ A common pattern: **pin** a section, then as the user scrolls **vertically**, co
|
||||||
|
|
||||||
1. Pin the section (trigger = the full-viewport panel).
|
1. Pin the section (trigger = the full-viewport panel).
|
||||||
2. Build a tween that animates the inner 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.
|
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**
|
3. Attach ScrollTrigger to that tween with **pin: true**, **scrub: true**
|
||||||
4. To trigger things based on the horizontal movement caused by that tween, set **containerAnimation** to that tween.
|
4. To trigger things based on the horizontal movement caused by that tween, set **containerAnimation** to that tween.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const scrollingEl = document.querySelector(".horizontal-el");
|
const scrollingEl = document.querySelector(".horizontal-el");
|
||||||
// Panel = pinned viewport-sized section. .horizontal-wrap = inner content that moves left.
|
// Panel = pinned viewport-sized section. .horizontal-wrap = inner content that moves left.
|
||||||
const scrollTween = gsap.to(scrollingEl, {
|
const scrollTween = gsap.to(scrollingEl, {
|
||||||
x: () => Math.min(0, window.innerWidth - scrollingEl.scrollWidth),
|
x: () => Math.min(0, window.innerWidth - scrollingEl.scrollWidth),
|
||||||
ease: "none", // ease: "none" is required
|
ease: "none", // ease: "none" is required
|
||||||
scrollTrigger: {
|
scrollTrigger: {
|
||||||
|
|
@ -255,7 +255,7 @@ const scrollTween = gsap.to(scrollingEl, {
|
||||||
invalidateOnRefresh: true,
|
invalidateOnRefresh: true,
|
||||||
scrub: true
|
scrub: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// other tweens that trigger based on horizontal movement should reference the containerAnimation:
|
// other tweens that trigger based on horizontal movement should reference the containerAnimation:
|
||||||
gsap.to(".nested-el-1", {
|
gsap.to(".nested-el-1", {
|
||||||
|
|
@ -288,7 +288,7 @@ In React, use the `useGSAP()` hook (@gsap/react NPM package) to ensure proper cl
|
||||||
|
|
||||||
- ✅ **gsap.registerPlugin(ScrollTrigger)** once before any ScrollTrigger usage.
|
- ✅ **gsap.registerPlugin(ScrollTrigger)** once before any ScrollTrigger usage.
|
||||||
- ✅ Call **ScrollTrigger.refresh()** after DOM/layout changes (new content, images, fonts) that affect trigger positions. Whenever the viewport is resized, `ScrollTrigger.refresh()` is automatically called (debounced 200ms)
|
- ✅ Call **ScrollTrigger.refresh()** after DOM/layout changes (new content, images, fonts) that affect trigger positions. Whenever the viewport is resized, `ScrollTrigger.refresh()` is automatically called (debounced 200ms)
|
||||||
- ✅ In React, use the `useGSAP()` hook to ensure that all ScrollTriggers and GSAP animations are reverted and cleaned up when necessary, or use a `gsap.context()` to do it manually in a useEffect/useLayoutEffect cleanup function.
|
- ✅ In React, use the `useGSAP()` hook to ensure that all ScrollTriggers and GSAP animations are reverted and cleaned up when necessary, or use a `gsap.context()` to do it manually in a useEffect/useLayoutEffect cleanup function.
|
||||||
- ✅ Use **scrub** for scroll-linked progress or **toggleActions** for discrete play/reverse; do not use both on the same trigger.
|
- ✅ Use **scrub** for scroll-linked progress or **toggleActions** for discrete play/reverse; do not use both on the same trigger.
|
||||||
- ✅ For fake horizontal scroll with **containerAnimation**, use **ease: "none"** on the horizontal tween/timeline so scroll and horizontal position stay in sync.
|
- ✅ For fake horizontal scroll with **containerAnimation**, use **ease: "none"** on the horizontal tween/timeline so scroll and horizontal position stay in sync.
|
||||||
- ✅ Create ScrollTriggers in the order they appear on the page (top to bottom, scroll 0 → max). When they are created in a different order (e.g. dynamic or async), set **refreshPriority** on each so they are refreshed in that same top-to-bottom order (first section on page = lower number).
|
- ✅ Create ScrollTriggers in the order they appear on the page (top to bottom, scroll 0 → max). When they are created in a different order (e.g. dynamic or async), set **refreshPriority** on each so they are refreshed in that same top-to-bottom order (first section on page = lower number).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue