fix(html-embed): correct stale iframe height and damp the resize loop

Address the non-test code-review findings on the htmlEmbed sandbox change
(test-coverage gaps are tracked in issue #99):

- html-embed-view: track the iframe's reported content height even while a
  fixed height is set, so clearing the height (fixed -> auto) without editing
  the source no longer leaves the frame pinned to the stale value. Derive the
  fixed-height predicate once; seed autoHeight to the default.
- html-embed-view: drop width/border from the iframe inline style (the
  .htmlEmbedFrame CSS class already provides them).
- html-embed-sandbox: coalesce height reports via requestAnimationFrame and
  skip <=1px deltas to damp the self-measure feedback loop; fix the misleading
  bootstrap comment.
- tracker-settings: add an aria-label to the snippet Textarea (a11y).
- CHANGELOG: note the removal of server-side role-based HTML-embed stripping.
This commit is contained in:
claude_code
2026-06-21 03:50:17 +03:00
parent bed3d3d286
commit c596e17a40
4 changed files with 46 additions and 18 deletions

View File

@@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
when the workspace HTML-embed toggle is on, can be inserted by any member when the workspace HTML-embed toggle is on, can be inserted by any member
(previously admin-only). Turning the toggle off hides existing embeds and (previously admin-only). Turning the toggle off hides existing embeds and
stops serving them on public share pages. stops serving them on public share pages.
- Remove the server-side role-based stripping of HTML-embed blocks from the
write paths (collab/REST/MCP, page create/duplicate, import, transclusion
unsync); sandboxing makes per-write gating unnecessary. The only remaining
server-side strip is the public-share read path, which still honors the
workspace HTML-embed toggle.
## [0.91.0] - 2026-06-18 ## [0.91.0] - 2026-06-18

View File

@@ -22,20 +22,42 @@ export function buildSandboxSrcdoc(source: string): string {
const bootstrap = ` const bootstrap = `
<script> <script>
(function () { (function () {
function reportHeight() { var lastSent = -1;
var scheduled = false;
function measure() {
var doc = document.documentElement; var doc = document.documentElement;
var body = document.body; var body = document.body;
var height = Math.max( return Math.max(
doc ? doc.scrollHeight : 0, doc ? doc.scrollHeight : 0,
body ? body.scrollHeight : 0 body ? body.scrollHeight : 0
); );
}
function flush() {
scheduled = false;
var height = measure();
// Only report when the height actually changed by more than 1px. This
// damps the iframe self-measure feedback loop: content sized to the iframe
// viewport would otherwise oscillate as the parent resizes the frame in
// response to each report.
if (Math.abs(height - lastSent) <= 1) return;
lastSent = height;
parent.postMessage( parent.postMessage(
{ type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height }, { type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height },
"*" "*"
); );
} }
function reportHeight() {
if (scheduled) return;
scheduled = true;
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(flush);
} else {
flush();
}
}
window.addEventListener("load", reportHeight); window.addEventListener("load", reportHeight);
// Report immediately too, in case load already fired. // Report an initial height now (runs during parse, before load/images
// settle); the load handler and ResizeObserver refine it as content changes.
reportHeight(); reportHeight();
if (typeof ResizeObserver !== "undefined") { if (typeof ResizeObserver !== "undefined") {
try { try {

View File

@@ -62,21 +62,24 @@ export default function HtmlEmbedView(props: NodeViewProps) {
const [draft, setDraft] = useState<string>(source || ""); const [draft, setDraft] = useState<string>(source || "");
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? ""); const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
// Auto-resize height tracked in state (used only when no fixed height is set). // True when the author pinned an explicit height; otherwise we auto-resize to
const [autoHeight, setAutoHeight] = useState<number>( // the iframe's reported content height.
typeof height === "number" && Number.isFinite(height) const hasFixedHeight = typeof height === "number" && Number.isFinite(height);
? height
: DEFAULT_IFRAME_HEIGHT, // Auto-resize height tracked in state. Seeded to the default and updated from
); // the iframe's postMessage reports (see effect below) regardless of mode, so
// switching a fixed-height embed back to auto immediately reflects the last
// reported content height instead of staying pinned to the old fixed value.
const [autoHeight, setAutoHeight] = useState<number>(DEFAULT_IFRAME_HEIGHT);
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]); const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
// Auto-resize: accept height messages ONLY from this iframe's own content // Auto-resize: accept height messages ONLY from this iframe's own content
// window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot // window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot
// match by event.origin — we match by event.source instead. No-op when a // match by event.origin — we match by event.source instead. We track the
// fixed height is configured. // reported height even while a fixed height is in effect, so toggling back to
// auto shows the current content height with no iframe reload.
useEffect(() => { useEffect(() => {
if (typeof height === "number" && Number.isFinite(height)) return;
function onMessage(event: MessageEvent) { function onMessage(event: MessageEvent) {
if (event.source !== iframeRef.current?.contentWindow) return; if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data as { type?: string; height?: number }; const data = event.data as { type?: string; height?: number };
@@ -87,12 +90,9 @@ export default function HtmlEmbedView(props: NodeViewProps) {
} }
window.addEventListener("message", onMessage); window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage);
}, [height]); }, []);
const effectiveHeight = const effectiveHeight = hasFixedHeight ? clampHeight(height) : autoHeight;
typeof height === "number" && Number.isFinite(height)
? clampHeight(height)
: autoHeight;
const openEditor = useCallback(() => { const openEditor = useCallback(() => {
setDraft(source || ""); setDraft(source || "");
@@ -157,7 +157,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
srcDoc={srcdoc} srcDoc={srcdoc}
title={t("HTML embed")} title={t("HTML embed")}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
style={{ width: "100%", border: "none", height: effectiveHeight }} style={{ height: effectiveHeight }}
/> />
) : canEdit ? ( ) : canEdit ? (
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}> <div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>

View File

@@ -78,6 +78,7 @@ export default function TrackerSettings() {
autosize autosize
minRows={6} minRows={6}
maxRows={20} maxRows={20}
aria-label={t("Analytics / tracker")}
value={value} value={value}
onChange={(e) => setValue(e.currentTarget.value)} onChange={(e) => setValue(e.currentTarget.value)}
placeholder={t("<script>...</script>")} placeholder={t("<script>...</script>")}