mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Opening Tweaks could leave the HTML preview iframe blank when the
host posted `od:srcdoc-transport-activate` before the lazy transport
shell had registered its message listener. The dropped activation
was then suppressed by the dedupe check in subsequent onLoad calls,
stranding the iframe on the 536-byte empty shell.
The race surfaces after the iframe re-mounts: closing Tweaks
increments `srcDocTransportResetKey`, which re-mounts the srcDoc
iframe with a fresh shell. Re-opening Tweaks immediately fires the
`useUrlLoadPreview`-driven `activateSrcDocTransport()` while the new
shell is still loading. The post lands in a window with no listener
yet, but `activatedSrcDocTransportHtmlRef.current` is marked to the
current srcDoc, so the iframe's onLoad call later short-circuits.
Fix:
- The lazy transport shell now posts `od:srcdoc-transport-ready`
to its parent once its activate-listener is installed.
- FileViewer tracks `srcDocShellReady`, reset on iframe re-mount
and set when ready arrives (with onLoad as belt-and-suspenders).
- Activation is funneled through a new pure helper
`canActivateSrcDocTransport()` that adds the ready gate to the
existing pre-conditions. When ready flips later, the
`activateSrcDocTransport` useCallback regenerates and the
enclosing useEffect re-fires the activation cleanly.
Tests cover the shell handshake (red on main: shell never posts
ready) and the gating helper (red on main: function did not exist).
Fixes #2253
199 lines
6.4 KiB
TypeScript
199 lines
6.4 KiB
TypeScript
import vm from 'node:vm';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
buildLazySrcdocTransport,
|
|
canActivateSrcDocTransport,
|
|
type SrcDocActivationInputs,
|
|
} from '../../src/runtime/srcdoc';
|
|
|
|
function extractShellScript(shellHtml: string): string {
|
|
const match = shellHtml.match(
|
|
/<script\s+data-od-lazy-srcdoc-transport>([\s\S]*?)<\/script>/,
|
|
);
|
|
if (!match || match[1] == null) {
|
|
throw new Error('lazy transport shell script not found');
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
interface RunShellResult {
|
|
parentMessages: unknown[];
|
|
triggerActivate: (html: string) => void;
|
|
}
|
|
|
|
function runShellInSandbox(shellHtml: string): RunShellResult {
|
|
const script = extractShellScript(shellHtml);
|
|
const parentMessages: unknown[] = [];
|
|
const messageListeners: Array<(ev: { data: unknown }) => void> = [];
|
|
const parentMock = {
|
|
postMessage: (data: unknown) => {
|
|
parentMessages.push(data);
|
|
},
|
|
};
|
|
const documentMock = {
|
|
open: vi.fn(),
|
|
write: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
const win = {
|
|
parent: parentMock,
|
|
addEventListener(_type: string, listener: (ev: { data: unknown }) => void) {
|
|
messageListeners.push(listener);
|
|
},
|
|
};
|
|
const sandbox: Record<string, unknown> = {
|
|
document: documentMock,
|
|
window: win,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(script, sandbox);
|
|
return {
|
|
parentMessages,
|
|
triggerActivate: (html: string) => {
|
|
for (const listener of messageListeners) {
|
|
listener({ data: { type: 'od:srcdoc-transport-activate', html } });
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('buildLazySrcdocTransport (#2253)', () => {
|
|
it('posts od:srcdoc-transport-ready to parent on load', () => {
|
|
const shell = buildLazySrcdocTransport();
|
|
const { parentMessages } = runShellInSandbox(shell);
|
|
expect(parentMessages).toContainEqual({ type: 'od:srcdoc-transport-ready' });
|
|
});
|
|
|
|
it('skips the ready post when window.parent equals window (top-level load)', () => {
|
|
// When the lazy shell is somehow opened top-level (no parent), the ready
|
|
// message must not throw and must not fan out to itself.
|
|
const script = extractShellScript(buildLazySrcdocTransport());
|
|
const calls: unknown[] = [];
|
|
const win: Record<string, unknown> = {
|
|
addEventListener: () => {},
|
|
postMessage: (data: unknown) => calls.push(data),
|
|
};
|
|
win.parent = win;
|
|
const sandbox: Record<string, unknown> = {
|
|
document: { open: () => {}, write: () => {}, close: () => {} },
|
|
window: win,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(script, sandbox);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it('still replaces document content when parent posts activate', () => {
|
|
const shell = buildLazySrcdocTransport();
|
|
const result = runShellInSandbox(shell);
|
|
result.triggerActivate('<html><body>activated</body></html>');
|
|
// The shell handler calls document.open/write/close in order.
|
|
// We assert behavior via the document mock the sandbox exposed.
|
|
// (Re-running with our own probe to inspect document mock.)
|
|
const script = extractShellScript(shell);
|
|
const writes: string[] = [];
|
|
const win: Record<string, unknown> = {
|
|
addEventListener(_t: string, listener: (ev: { data: unknown }) => void) {
|
|
(win as { __listener: typeof listener }).__listener = listener;
|
|
},
|
|
};
|
|
win.parent = { postMessage: () => {} };
|
|
const sandbox: Record<string, unknown> = {
|
|
document: {
|
|
open: () => {},
|
|
write: (chunk: string) => writes.push(chunk),
|
|
close: () => {},
|
|
},
|
|
window: win,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(script, sandbox);
|
|
const listener = (win as { __listener: (ev: { data: unknown }) => void }).__listener;
|
|
listener({ data: { type: 'od:srcdoc-transport-activate', html: '<p>hi</p>' } });
|
|
expect(writes).toEqual(['<p>hi</p>']);
|
|
});
|
|
|
|
it('ignores activate messages with missing or non-string html', () => {
|
|
const shell = buildLazySrcdocTransport();
|
|
const script = extractShellScript(shell);
|
|
const writes: string[] = [];
|
|
const win: Record<string, unknown> = {
|
|
addEventListener(_t: string, listener: (ev: { data: unknown }) => void) {
|
|
(win as { __listener: typeof listener }).__listener = listener;
|
|
},
|
|
};
|
|
win.parent = { postMessage: () => {} };
|
|
const sandbox: Record<string, unknown> = {
|
|
document: {
|
|
open: () => {},
|
|
write: (chunk: string) => writes.push(chunk),
|
|
close: () => {},
|
|
},
|
|
window: win,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(script, sandbox);
|
|
const listener = (win as { __listener: (ev: { data: unknown }) => void }).__listener;
|
|
listener({ data: { type: 'od:srcdoc-transport-activate' } });
|
|
listener({ data: { type: 'od:srcdoc-transport-activate', html: 123 } });
|
|
listener({ data: null });
|
|
listener({ data: { type: 'unrelated' } });
|
|
expect(writes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
const BASE_STATE: SrcDocActivationInputs = {
|
|
srcDoc: '<html>real</html>',
|
|
useUrlLoadPreview: false,
|
|
useLazySrcDocTransport: true,
|
|
shellReady: true,
|
|
activatedHtml: null,
|
|
};
|
|
|
|
describe('canActivateSrcDocTransport (#2253)', () => {
|
|
it('returns true when shell is ready and we are in srcDoc mode', () => {
|
|
expect(canActivateSrcDocTransport(BASE_STATE)).toBe(true);
|
|
});
|
|
|
|
it('returns false when shell is not yet ready (the #2253 race)', () => {
|
|
expect(
|
|
canActivateSrcDocTransport({ ...BASE_STATE, shellReady: false }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false when host is still URL-loading the preview', () => {
|
|
expect(
|
|
canActivateSrcDocTransport({ ...BASE_STATE, useUrlLoadPreview: true }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false when the lazy transport is bypassed', () => {
|
|
expect(
|
|
canActivateSrcDocTransport({ ...BASE_STATE, useLazySrcDocTransport: false }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false when srcDoc is empty (no real artifact yet)', () => {
|
|
expect(canActivateSrcDocTransport({ ...BASE_STATE, srcDoc: '' })).toBe(false);
|
|
});
|
|
|
|
it('returns false when the same html was already activated (dedupe)', () => {
|
|
expect(
|
|
canActivateSrcDocTransport({
|
|
...BASE_STATE,
|
|
activatedHtml: BASE_STATE.srcDoc,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns true when activatedHtml differs from current srcDoc', () => {
|
|
expect(
|
|
canActivateSrcDocTransport({
|
|
...BASE_STATE,
|
|
activatedHtml: '<html>previous</html>',
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|