@plannotator/ui 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,7 @@ Building your own tooltip and removing the built-in double-click reset are host-
54
54
 
55
55
  The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
56
56
 
57
- - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3).
57
+ - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias.
58
58
  - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
59
59
  - **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
60
60
 
@@ -102,16 +102,28 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
102
102
 
103
103
  Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
104
104
 
105
- - **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names.
106
- - **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side.
105
+ - **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection.
106
+ - **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict.
107
107
  - **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
108
108
  - **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
109
109
  - **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards.
110
- - **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too).
110
+ - **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too). With the cap enforced upstream (bridge toggle, parent trust boundary on submit and on restore, `projectHostThreads` `maxTargets` on read), a composed comment never reaches the host with more targets than the cap, so a host's own cap-dropped handling (`capDroppedTargets` from `buildPersistedHtmlAnchor`, or a message-counting listener) is unreachable in normal operation; keep it only as a backstop for rows written by an older host build or by another writer. Byte-budget drops (`sizeDroppedTargets`) are a separate path and remain reachable.
111
111
  - An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll.
112
112
 
113
113
  See HANDOFF.md § "HTML annotation parity seams".
114
114
 
115
+ #### The bridge script as an asset (`bridgeScriptUrl`; 0.33.0)
116
+
117
+ By default `HtmlViewer` inlines its 185 KB in-page bridge script into every srcdoc document. A host that serves the package's generated `components/html-viewer/bridge-script.asset.js` as a static file can pass its URL instead:
118
+
119
+ ```ts
120
+ import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";
121
+
122
+ <HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
123
+ ```
124
+
125
+ The srcdoc then carries one classic `<script src>` in the exact place the inline script sat (at the end of `<head>`, before the body), the browser caches the asset across documents, and the bridge's `ready` message carries `BRIDGE_PROTOCOL_VERSION`, which the viewer checks: a stale cached asset (no stamp, or another version) logs one console warning naming both versions and shows a dismissible error banner in the surface (`onBridgeUnavailable` fires too); no `ready` within `bridgeReadyTimeoutMs` (default 5000) shows a timeout banner. The URL is resolved against your document's base (`document.baseURI`) before it is written into the srcdoc, never against the framed page, so a page's own `<base href>` cannot redirect it. Plannotator passes nothing and stays inline; none of this runs on the inline path. **CSP:** the package sets no CSP `<meta>` in the srcdoc document, and the frame is an opaque origin so the classic script needs no CORS (no `crossorigin` is set), but a CSP delivered as a header on your page is inherited by the frame: allow `script-src` for the asset's origin. Because the frame is an opaque origin, an asset served with `Cross-Origin-Resource-Policy: same-origin` (common alongside COEP) is blocked; serve it with a CORP that admits cross-origin loads, or without CORP. To also drop the inline literal from your viewer chunk, alias the package's `./bridge-script` resolution to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").
126
+
115
127
  #### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
116
128
 
117
129
  - **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
@@ -152,7 +164,7 @@ npm install @plannotator/ui @plannotator/core
152
164
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
153
165
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
154
166
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
155
- - Versioned in lockstep with the repo (currently `@plannotator/core` 0.25.0 with `@plannotator/ui` 0.32.0). Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
167
+ - Versioned together (currently `@plannotator/ui` 0.33.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 is such a release, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
156
168
 
157
169
  ## The one rule
158
170
 
@@ -41,6 +41,8 @@ import { buildSyncNumbering } from "./annotationNumbering";
41
41
  import { mergeUnanchoredIds } from "./unanchored";
42
42
  import {
43
43
  MAX_PAGE_URL_LENGTH,
44
+ checkBridgeProtocolVersion,
45
+ formatBridgeProtocolWarning,
44
46
  rejectsLiveMessage,
45
47
  useHtmlAnnotation,
46
48
  type HtmlLiveSession,
@@ -51,6 +53,7 @@ import {
51
53
  buildThemeTokenPayload,
52
54
  hasHostThemeOptIn,
53
55
  injectIntoHead,
56
+ resolveBridgeScriptUrl,
54
57
  } from "./srcdoc";
55
58
 
56
59
  const PREFIX = "plannotator-bridge-";
@@ -131,6 +134,38 @@ function parseVimBridgeHelp(value: unknown): boolean | null {
131
134
 
132
135
  const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
133
136
 
137
+ /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
138
+ export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
139
+
140
+ /**
141
+ * Why the bridge could not be established on the `bridgeScriptUrl` path.
142
+ * Never produced on the inline path (the bridge and the parent are one
143
+ * bundle there, and no ready timer runs).
144
+ */
145
+ export type BridgeUnavailableInfo =
146
+ | {
147
+ kind: "timeout";
148
+ url: string;
149
+ /** The wait that elapsed without a `ready`. */
150
+ timeoutMs: number;
151
+ }
152
+ | {
153
+ kind: "version-mismatch";
154
+ url: string;
155
+ expectedVersion: number;
156
+ /** Absent when the ready carried no stamp (a pre-stamp asset). */
157
+ reportedVersion?: number;
158
+ };
159
+
160
+ /** User-facing message for the in-surface error banner. */
161
+ export function formatBridgeUnavailableMessage(info: BridgeUnavailableInfo): string {
162
+ if (info.kind === "timeout") {
163
+ return `Annotation tools did not load: the bridge script at ${info.url} sent no ready signal within ${info.timeoutMs} ms. The page is shown without annotation. Check that the URL is reachable and that your Content Security Policy allows script-src for that origin.`;
164
+ }
165
+ const reported = info.reportedVersion === undefined ? "no version" : `version ${info.reportedVersion}`;
166
+ return `Annotation tools may not work: this viewer expects bridge protocol version ${info.expectedVersion}, but the script at ${info.url} reported ${reported}. Serve the bridge-script asset from the same @plannotator/ui version as the viewer.`;
167
+ }
168
+
134
169
  function parseVimBridgeCopy(value: unknown): string | null {
135
170
  return isRecord(value)
136
171
  && value.type === `${PREFIX}vim-copy`
@@ -221,6 +256,34 @@ export interface HtmlViewerProps {
221
256
  scrollBehavior?: 'smooth' | 'auto';
222
257
  /** Accessible iframe title. */
223
258
  title?: string;
259
+ /**
260
+ * Opt-in: load the annotation bridge into the srcdoc document through a
261
+ * classic `<script src>` from this URL (the package's generated
262
+ * `components/html-viewer/bridge-script.asset.js`, served by the host)
263
+ * instead of inlining the 185 KB script into every document. Absent (the
264
+ * default, and Plannotator's only path): inline, unchanged. The tag lands
265
+ * where the inline script does, at the end of `<head>`, before the body.
266
+ * The URL is resolved against THIS document's base (`document.baseURI`)
267
+ * before it is written, never against the framed page, so a page's own
268
+ * `<base href>` cannot redirect it. The srcdoc frame is an opaque origin,
269
+ * so the script needs no CORS and no `crossorigin` attribute is set; a CSP
270
+ * header on the host page is inherited by the frame and must allow
271
+ * `script-src` for the asset origin, and the asset must not be served with
272
+ * `Cross-Origin-Resource-Policy: same-origin`. Ignored in live (`src`)
273
+ * mode, where the proxy injects the bridge.
274
+ */
275
+ bridgeScriptUrl?: string;
276
+ /**
277
+ * How long to wait for the bridge's `ready` after each document load on
278
+ * the `bridgeScriptUrl` path before the surface shows an error state.
279
+ * Default 5000 ms. No timer runs on the inline path.
280
+ */
281
+ bridgeReadyTimeoutMs?: number;
282
+ /** The bridge could not be established on the `bridgeScriptUrl` path (no
283
+ * ready within the timeout, or a protocol version mismatch). The surface
284
+ * shows its own banner as well; this lets the host react (telemetry, a
285
+ * retry affordance). Never called on the inline path. */
286
+ onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
224
287
  }
225
288
 
226
289
  /**
@@ -263,6 +326,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
263
326
  maxAdditionalTargets,
264
327
  scrollBehavior,
265
328
  title = "HTML Plan Viewer",
329
+ bridgeScriptUrl,
330
+ bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
331
+ onBridgeUnavailable,
266
332
  },
267
333
  ref,
268
334
  ) => {
@@ -320,6 +386,18 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
320
386
  // themselves); arbitrary HTML renders untouched, like a standalone tab.
321
387
  const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
322
388
 
389
+ // The URL path is srcdoc-only: live mode has the proxy inject the bridge.
390
+ // Resolved against THIS document's base before it is written into the
391
+ // srcdoc, so a framed page's own <base href> can never re-anchor it.
392
+ const bridgeUrl = useMemo(
393
+ () => (!liveMode && bridgeScriptUrl
394
+ ? resolveBridgeScriptUrl(bridgeScriptUrl, document.baseURI)
395
+ : undefined),
396
+ [liveMode, bridgeScriptUrl],
397
+ );
398
+ const bridgeUrlRef = useRef(bridgeUrl);
399
+ bridgeUrlRef.current = bridgeUrl;
400
+
323
401
  const srcdoc = useMemo(() => {
324
402
  if (liveMode) return undefined; // src mode: the proxy injects the bridge
325
403
  const injection = buildSrcdocInjection({
@@ -327,9 +405,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
327
405
  isLight: isLightTheme(),
328
406
  hostTheme,
329
407
  diffActive: !!diffActive,
408
+ bridgeScriptUrl: bridgeUrl,
330
409
  });
331
410
  return injectIntoHead(rawHtml, injection);
332
- }, [liveMode, rawHtml, hostTheme, diffActive]);
411
+ }, [liveMode, rawHtml, hostTheme, diffActive, bridgeUrl]);
412
+
413
+ // Error state for the bridgeScriptUrl path only: the inline path never
414
+ // sets it (no timer, and a version mismatch there can only be a forged
415
+ // message, which is warned about and otherwise ignored).
416
+ const [bridgeError, setBridgeError] = useState<BridgeUnavailableInfo | null>(null);
417
+ // A version-mismatch banner is dismissible (the older bridge keeps
418
+ // working); reset whenever the error itself changes.
419
+ const [bridgeErrorDismissed, setBridgeErrorDismissed] = useState(false);
420
+ const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
421
+ const onBridgeUnavailableRef = useRef(onBridgeUnavailable);
422
+ onBridgeUnavailableRef.current = onBridgeUnavailable;
423
+ // The timeout is read through a ref at arming time: the timer is armed
424
+ // once per document load (URL or srcdoc change), never re-armed by a
425
+ // later prop change, so a host adjusting bridgeReadyTimeoutMs after the
426
+ // bridge is ready can never produce a false timeout.
427
+ const bridgeReadyTimeoutMsRef = useRef(bridgeReadyTimeoutMs);
428
+ bridgeReadyTimeoutMsRef.current = bridgeReadyTimeoutMs;
429
+ useEffect(() => {
430
+ if (!bridgeUrl || srcdoc === undefined) return;
431
+ setBridgeError(null);
432
+ setBridgeErrorDismissed(false);
433
+ const url = bridgeUrl;
434
+ const timeoutMs = bridgeReadyTimeoutMsRef.current;
435
+ readyTimerRef.current = setTimeout(() => {
436
+ readyTimerRef.current = null;
437
+ setBridgeError({ kind: "timeout", url, timeoutMs });
438
+ }, timeoutMs);
439
+ return () => {
440
+ if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current);
441
+ readyTimerRef.current = null;
442
+ };
443
+ }, [bridgeUrl, srcdoc]);
444
+ useEffect(() => {
445
+ setBridgeErrorDismissed(false);
446
+ if (bridgeError) onBridgeUnavailableRef.current?.(bridgeError);
447
+ }, [bridgeError]);
333
448
 
334
449
  const handleResize = useCallback((height: number) => {
335
450
  if (liveMode) return; // live surfaces are full-viewport; height is ignored
@@ -516,6 +631,33 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
516
631
  const live = liveSessionRef.current;
517
632
  if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
518
633
  if (isBridgeReadyMessage(e.data)) {
634
+ // Protocol stamp: one console warning on drift, naming both
635
+ // versions. The ready is still honored (an older bridge answers
636
+ // every message shape it knows); on the bridgeScriptUrl path the
637
+ // surface additionally shows its error banner, because there the
638
+ // drift is a real deployment state (a cached asset from a previous
639
+ // package version) rather than a forged message.
640
+ const verdict = checkBridgeProtocolVersion(e.data);
641
+ const url = bridgeUrlRef.current;
642
+ if (!verdict.ok) {
643
+ console.warn(formatBridgeProtocolWarning(verdict, url));
644
+ }
645
+ if (url) {
646
+ if (readyTimerRef.current !== null) {
647
+ clearTimeout(readyTimerRef.current);
648
+ readyTimerRef.current = null;
649
+ }
650
+ setBridgeError(
651
+ verdict.ok
652
+ ? null
653
+ : {
654
+ kind: "version-mismatch",
655
+ url,
656
+ expectedVersion: verdict.expected,
657
+ reportedVersion: verdict.reported,
658
+ },
659
+ );
660
+ }
519
661
  setIframeReadyVersion((version) => version + 1);
520
662
  setVimBridgePhase("inactive");
521
663
  setVimHudCommand(null);
@@ -916,6 +1058,37 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
916
1058
  {actionButtons}
917
1059
  </div>
918
1060
  )}
1061
+ {/* bridgeScriptUrl path only: the bridge did not come up (no
1062
+ ready within the timeout, or a stale asset's version). Floated
1063
+ over the top of the iframe so it never changes the layout the
1064
+ page renders in; the page itself stays visible. */}
1065
+ {bridgeError && !bridgeErrorDismissed && (
1066
+ <div
1067
+ role="alert"
1068
+ data-print-hide
1069
+ data-bridge-error={bridgeError.kind}
1070
+ className="absolute inset-x-0 top-0 z-20 border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive backdrop-blur-sm"
1071
+ style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
1072
+ >
1073
+ <span style={{ flex: 1 }}>{formatBridgeUnavailableMessage(bridgeError)}</span>
1074
+ {/* Only the mismatch state is dismissible: the older bridge
1075
+ still works there. A timeout leaves a dead surface, so
1076
+ that banner stays. Inline styles on purpose: hosts that
1077
+ build the guides.show viewer scan this file for utility
1078
+ classes, and this banner must not grow that stylesheet. */}
1079
+ {bridgeError.kind === "version-mismatch" && (
1080
+ <button
1081
+ type="button"
1082
+ data-bridge-error-dismiss
1083
+ aria-label="Dismiss"
1084
+ style={{ flexShrink: 0, borderRadius: 4, padding: "2px 6px", fontWeight: 500, cursor: "pointer", background: "transparent", border: "1px solid currentColor", color: "inherit" }}
1085
+ onClick={() => setBridgeErrorDismissed(true)}
1086
+ >
1087
+ Dismiss
1088
+ </button>
1089
+ )}
1090
+ </div>
1091
+ )}
919
1092
  {/* Live proxied-app mode navigates a real loopback origin: no
920
1093
  sandbox (the user's own app needs cookies, storage, and
921
1094
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}