feat(embed): MountHandle.getPayload() reads the current payload - #116
Conversation
|
There was a problem hiding this comment.
Pull request overview
Adds MountHandle.getPayload() for direct, synchronized payload reads without DOM-scraping Save controls.
Changes:
- Adds payload-source bridge and public API.
- Flushes pending YAML edits before serialization.
- Documents and tests lifecycle edge cases.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/embed/types.ts |
Defines public API. |
src/embed/host.ts |
Adds payload-source registration. |
src/embed/mount.tsx |
Implements payload reads and fallback. |
src/ui/App.tsx |
Registers synchronized serializer. |
src/ui/hooks/useProjectState.ts |
Exposes current elements snapshot. |
src/ui/components/YamlPanel.tsx |
Exposes pending-edit flush. |
tests/embed/get-payload.test.tsx |
Covers payload-read behavior. |
docs/embedding.md |
Documents API semantics. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Nothing registered yet (pre-registration window, or the initial | ||
| // bootstrap load is still in flight for an async host): report the | ||
| // bootstrap payload — the same `elements` the shell is about to seed | ||
| // its state from — rather than throwing or returning nothing. | ||
| return serializeYamlPayload(bootstrap?.elements ?? []) |
There was a problem hiding this comment.
Confirmed — fixed in 6ae6226: the pre-registration fallback now tracks the latest queued setPayload's parsed elements (last-wins, synchronous regression tests incl. destroy-before-registration).
| * - **Never throws, never returns `undefined`** — including the brief | ||
| * window right after `mount()`/`mountStandaloneApp()` return but before | ||
| * React has committed and run its effects, when it reports the bootstrap | ||
| * payload the designer is about to render. |
There was a problem hiding this comment.
Already fixed in 060971d — the guarantee is scoped to mounted handles, post-destroy throw documented and pinned by test.
| - **Before React has committed anything** (a synchronous call right after | ||
| `mount()`/`mountStandaloneApp()` returns, before the mount's internal push/read | ||
| registration effect has run): reports the **bootstrap payload** — the same | ||
| `elements` the shell is about to seed its state from for a synchronous host | ||
| (`mount({ payload })`), or a safe empty-list default while an async | ||
| bootstrap (the standalone SPA's IndexedDB/share-hash load) is still in | ||
| flight. Never throws, never returns `undefined`. |
There was a problem hiding this comment.
Already fixed in 060971d — the guarantee is scoped to mounted handles, post-destroy throw documented and pinned by test.
|
Merge order for the three useProjectState PRs (#117 → #114 → #116): all three edit the same |
f3a3892 to
7c1ef47
Compare
Adds getPayload() to MountHandle (mount() and mountStandaloneApp()) so a host can read the designer's current drawcustom YAML directly, instead of DOM-scraping the shadow root for the Save button and simulating a click — the upstream OpenDisplay HA integration (PR #100) did exactly that and hung silently whenever a YAML error had disabled the button. getPayload() reuses onSaveRequest's own serializer (no second source of truth) and resolves three edge cases so it can never disagree with what Save would send: - Before React has committed anything (a synchronous call right after mount()/mountStandaloneApp() returns): reports the bootstrap payload — never throws, never returns undefined. - Mid-keystroke, while the YAML editor's 80ms sync debounce is still pending: forces that flush before reading, matching what a real Save click sends (a click always blurs the editor first, flushing the debounce). - While the YAML editor is blocked by a parse/schema error (Save itself is disabled): returns the last valid payload, same as Save would have sent last. Implementation: extends the mount lifecycle's push-queue registration seam (mount.tsx) with a read mirror (registerPayloadSource), the inverse direction of registerPushTarget — a typed read of designer output per the ADR-018 seam grammar, no bidirectional shared state. useProjectState exposes a synchronous elements snapshot; YamlPanel exposes its live debounce-flush function via a ref so the registered getter can force it before serializing. Additive only — MountOptions/existing MountHandle members are unchanged. Tests: tests/embed/get-payload.test.tsx (red-first — all 7 failed with "getPayload is not a function" before this change): matches onSaveRequest via a real Save click, reflects a setPayload push, returns the bootstrap payload synchronously pre-effect-flush, never throws for a standalone handle called immediately, returns the last valid payload while blocked, and forces the debounce flush for a pending valid edit. Docs: docs/embedding.md documents the getPayload() semantics and the three edge-case decisions above. Closes #104 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `setPayload()` push replaces the payload wholesale, but a YAML edit still parked in the editor's 80ms debounce survived it and was committed by the next flush — over the payload the host had just pushed. `getPayload()`, documented as a pure read, forces exactly that flush, so a host doing `setPayload(next)` then `getPayload()` deterministically read back the pre-push draft instead of `next`. Make the push authoritative: `applyPayload` calls YamlPanel's new `discardPendingYamlEdit` in the same synchronous path, before committing the pushed elements, so the parked parse is gone and its timer cancelled before any render or effect can observe them. Clearing the parse also lets the existing (unannotated, ADR-009) external sync serialize the pushed payload into the editor instead of deferring to the draft, and clearing the self-echo suppression keeps a push that lands right after a flush from being mistaken for the designer's own echo. Also correct the `getPayload()` docs: it throws after `destroy()` like every other handle method, and never resurrects a pre-push draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d push The pre-registration `getPayload()` fallback serialized the original bootstrap even when a `setPayload()` had already been accepted into the pending-push queue during that same window — so `mount(...); handle.setPayload(next); handle.getPayload()` returned stale bootstrap YAML even though `next` is what the drained queue will apply as the designer's payload once `registerPushTarget` runs. Track the elements from the most recently queued `setPayload()` in the mount closure and serialize those in the fallback when present, falling back to bootstrap otherwise. Parsing/validation still happens in `setPayload` itself, so throw semantics on invalid YAML are unchanged. Adds regression coverage: a single pre-registration setPayload, several queued (last wins), and setPayload immediately followed by destroy() before registration (no leak, no throw weirdness). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integration testing against PR #117 (fix/issue-115-push-race) surfaced a commit-window defect: #117 makes registerPushTarget (useProjectState) a useLayoutEffect, but registerPayloadSource (App.tsx) stayed a passive useEffect. Between commit and the passive flush, a host push already applies live (pushTarget registered, so setPayload applies directly and the pendingPayloadElements fallback never records it) while getPayload() still falls back to the original bootstrap — stale YAML even though the DOM already shows the pushed payload. Fix: promote registerPayloadSource to useLayoutEffect, co-timed with registerPushTarget. Safe pre-#117 too: on this branch registerPushTarget is still passive, so registering the (pure, read-only) payload source earlier cannot misorder anything — it only closes the window. Same class in YamlPanel's usePublishedCallback (flushPendingRef / discardPendingRef): also promoted to useLayoutEffect. Benign today (no YAML draft can exist at the very first commit), but the same un-co-timed-registration pattern. Adds a red-before/green-after ordering-assertion test: React fires every layout effect in a tree before any passive effect, so promoting only registerPayloadSource flips the observed registration order even while registerPushTarget stays passive on this branch. The direct MutationObserver reproduction from the bug report needs both effects mismatched in *type* (i.e. after #117 lands), so it's kept here as a self-contained forward-looking regression guard instead. docs/embedding.md: note the co-timed registration contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elative order Both channels are layout effects after the issue #115 fix landed on main, so the pre-rebase premise (layout beats passive) is obsolete; the invariant is that no microtask can observe the push channel without the read channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
07e9687 to
dae67fc
Compare
… scoping (#138) Adversarial review of the layer-1 resolver found that several of the PR's claims were only true of the helpers, not of what the user sees. Each fix below comes with a test the reviewer's own sabotage fails. Major: - A synchronous mount failure left the resolver installed. The tier is installed before any DOM exists, so it now lives under the same pristine guarantee as the container (issue #116): `mountDesigner` keeps ONE `teardown` list, unwound by `destroy()` and by any failure before the first committed frame. Retrying a doomed mount no longer stacks orphan tiers. - A host that took a call and never settled it left the referencing elements mid-load forever — no asset and no error, the one outcome issue #10 forbids. Resolver calls now time out (`HOST_ASSET_TIMEOUT_MS`, 15s) and settle as the ordinary explicit render-error, with the retry window measured from there. No AbortSignal in this layer, deliberately. Medium: - `destroy()` forgot the mount's cache but not the bytes it produced: the parsed opentype font, the core font registry entry and the CSS `@font-face` outlived it, so a second host on the page inherited the first host's file. Caches now register with `registerHostAssetEvictor` and are pruned per `(kind, name)` on dispose; an evicted font is marked unavailable so text falls back to the explicit error, not to wrong metrics. - Host-added `FontFace` objects are tracked and removed from `document.fonts` on dispose — add count equals delete count. Content-map faces pre-date this tier and keep their existing document-lifetime behaviour. - Cache keys and `hasHostSuppliedAsset` are `(kind, name)`-scoped end to end (probe, Content Manager badge, missing-asset banner, dlimg prune), so a font answer can no longer badge or retain an image of the same name. The joiner is a JSON key, which also removes the literal NUL byte that made `host-resolver.ts` a binary blob in git (the issue #125 lesson); the other literal NULs in the repo, in `useStableAssetKeys.test.ts`, are escaped too. - Docs no longer imply a self-healing TTL: a declined asset is retried on the next asset-affecting load pass that runs after the 30s window — there is no background timer and nothing wakes the designer. Minor: - Two-mount semantics are pinned: most-recent-serves for new resolutions, and an asset either live mount was supplied stays recognised, so a second mount appearing cannot prune an image the first is painting. Docs state the lossy edge. - A host URL answer is fetched to an `ArrayBuffer` instead of interpolated into a CSS `url()`, so a media route serving `logo (1).ttf` loads instead of silently failing. - Badge wording matches the label the code renders (`Host`, `Missing`). - The demo host font is emitted from `src/assets/fonts/rbm.ttf` at build time (`tools/demoHostAssets.ts`) rather than committed as a second 165 KB copy. - `AssetKind` is exported from `src/embed`, so a host adapter can name the discriminator it switches on. Coverage added for the properties the reviewer disproved: tier order (an uploaded or bundled asset never reaches the resolver), first-frame install position, and standalone parity as a microtask probe rather than a helper's return value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fourth ADR-018 seam (issue #109): a host renders the payload itself — a real server-side render, not another client approximation — and the designer shows that image in place of its own preview. This is also the ADR-007 pixel-parity reference the ADR promised: a true Pillow render available inside the designer. Contract (additive, `src/embed/types.ts`): renderPreview?: (payload, { targetId, service }) => Promise<Blob | string> - The ids travel in a context object, like the actions seam, so issue #105's service-options seam lands additively. `service` is that slice, minimal today (`dither`, in the drawcustom service option's own `0 | 1 | 2` domain) and required rather than optional: the designer always knows its own dither mode, so a host never guards for it. - A stable closure fixed at mount — there is no `setRenderPreview()` (ADR-018: data is pushed, functions are not). UI (maintainer rulings 2026-08-16): - A **Display preview** toggle immediately right of the Canvas heading, rendered only where a provider exists — the same conditional-chrome rule actions and targets follow, so standalone gains nothing. - What shipped is a *mode*, not the ADR's sketched overlay: the host render replaces the paper's content, inheriting the existing zoom system instead of carrying a second one. - Editing pauses. Add-element buttons, canvas selection/drag/nudge, undo/redo, property fields, element reordering, Clear all and Load Demo are disabled exactly as they are while the YAML doc is blocked (issue #35) — one disabled-mutation concept, two reasons for it. The YAML editor goes read-only (new CodeMirror compartment) rather than hidden, so it stays selectable and copyable. - Copy PNG, Download PNG, zoom and the dither control stay live, acting on the host render; a dither change re-requests it, so the preview can never contradict the designer's own dither setting. - Re-requests are debounced (250ms) with a subtle loading chip, and each answer is matched to its request by token: a superseded slow render is discarded, never painted (the #115/#116 lesson, applied to responses). - A rejection (or synchronous throw) is stated in the preview area with the host's own message and drops the image — a clear error beats a stale or wrong render, and there is no silent fall back to the designer's own rasterization. The demo page implements the provider with a deliberately crude host-side rasterizer (`demo/preview-render.js`: its own monospace font, its own template resolution, its own 1-bit quantization) plus an artificial delay and a "Simulate preview failure" button. It is deliberately not the designer's renderer — the visible difference is the point of the seam, and it keeps the ADR-018 litmus test honest. Tests: tests/embed/host-preview.test.tsx (provider-gated chrome, request payload/context, inertness and restore, Copy PNG against the host render, dither re-request, stale-response discard, error state) and tests/e2e/embed-preview.spec.ts (real PNG round trip on the demo page, pixel signature changing with dither, editing live again on exit). Closes #109 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(embed): host-rendered Display preview seam (renderPreview) The fourth ADR-018 seam (issue #109): a host renders the payload itself — a real server-side render, not another client approximation — and the designer shows that image in place of its own preview. This is also the ADR-007 pixel-parity reference the ADR promised: a true Pillow render available inside the designer. Contract (additive, `src/embed/types.ts`): renderPreview?: (payload, { targetId, service }) => Promise<Blob | string> - The ids travel in a context object, like the actions seam, so issue #105's service-options seam lands additively. `service` is that slice, minimal today (`dither`, in the drawcustom service option's own `0 | 1 | 2` domain) and required rather than optional: the designer always knows its own dither mode, so a host never guards for it. - A stable closure fixed at mount — there is no `setRenderPreview()` (ADR-018: data is pushed, functions are not). UI (maintainer rulings 2026-08-16): - A **Display preview** toggle immediately right of the Canvas heading, rendered only where a provider exists — the same conditional-chrome rule actions and targets follow, so standalone gains nothing. - What shipped is a *mode*, not the ADR's sketched overlay: the host render replaces the paper's content, inheriting the existing zoom system instead of carrying a second one. - Editing pauses. Add-element buttons, canvas selection/drag/nudge, undo/redo, property fields, element reordering, Clear all and Load Demo are disabled exactly as they are while the YAML doc is blocked (issue #35) — one disabled-mutation concept, two reasons for it. The YAML editor goes read-only (new CodeMirror compartment) rather than hidden, so it stays selectable and copyable. - Copy PNG, Download PNG, zoom and the dither control stay live, acting on the host render; a dither change re-requests it, so the preview can never contradict the designer's own dither setting. - Re-requests are debounced (250ms) with a subtle loading chip, and each answer is matched to its request by token: a superseded slow render is discarded, never painted (the #115/#116 lesson, applied to responses). - A rejection (or synchronous throw) is stated in the preview area with the host's own message and drops the image — a clear error beats a stale or wrong render, and there is no silent fall back to the designer's own rasterization. The demo page implements the provider with a deliberately crude host-side rasterizer (`demo/preview-render.js`: its own monospace font, its own template resolution, its own 1-bit quantization) plus an artificial delay and a "Simulate preview failure" button. It is deliberately not the designer's renderer — the visible difference is the point of the seam, and it keeps the ADR-018 litmus test honest. Tests: tests/embed/host-preview.test.tsx (provider-gated chrome, request payload/context, inertness and restore, Copy PNG against the host render, dither re-request, stale-response discard, error state) and tests/e2e/embed-preview.spec.ts (real PNG round trip on the demo page, pixel signature changing with dither, editing live again on exit). Closes #109 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embed): preview needs valid YAML, carries canvas geometry, leaks nothing Maintainer rulings on the #109 preview seam review (2026-08-17). - Entering preview requires a parseable document: the toggle is disabled with a stated reason ("Fix the YAML errors to preview"), the same disabled-with-a-reason pattern payload-carrying host actions use. Previewing a blocked doc would render the last-valid payload while the editor shows something else. The converse is impossible — the editor is read-only inside preview and `setPayload()` parses or throws — so the YAML-error overlay can never explain a host render; the canvas now guards that structurally. - `HostPreviewContext` gains `display: { width, height, rotation }` — the oriented logical surface the payload's coordinates are authored against (issue #139). A resolution pick or re-orientation re-requests through the existing debounce and token machinery, and the demo host renders at that geometry instead of its own stored capabilities. - The request effect's cleanup retires its token, so a response arriving after unmount no longer mints an object URL nothing can revoke. - `renderPreview` must be a function at `mount()`, like a malformed actions/targets list. - A download taken in preview mode is named `display-preview-<session>.png` so it can sit beside the designer's own export and be diffed. - Docs: the debounce covers host pushes, display config, target and dither — not typing, which preview mode makes impossible; host action buttons stay enabled deliberately; the conditional-chrome claim is stated as visual parity, not DOM parity (the heading's flex wrapper ships unconditionally); the demo's frozen clock is documented as a request-time snapshot. - Tests: regression pins for payload-push and target re-requests, the URL-string path, a non-image answer, destroy-mid-request, the download name and the mount-time validation; `testTimeout: 30_000` on every full-mount embed file (the documented 5s-default gotcha). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embed): lock indicator, single-transition rotation, real demo preview, one-button add-display Four maintainer manual-validation findings on the Display preview seam (issue #109, ADR-018), fixed on PR #143's branch: 1. YAML header shows "YAML — locked by Display preview" while preview mode is active (readOnly), muted-token suffix, gone on exit. 2. A geometry change (resolution pick, re-orientation) while previewing now clears the image immediately — blank paper + loading chip — before the debounced re-request even fires, instead of letterboxing the stale image into the new canvas box for ~250ms (the video-evidence double resize). Dither/payload/target changes are unaffected: nothing about them can change the image's dimensions, so the old render still stays up in place until the new one lands. 3. Root cause of the demo's "| -" garbage: demo/preview-render.js parsed drawcustom YAML with a line-by-line `key: value` regex that could not handle a block scalar (`value: |-` / `>-`), which the real serializer emits for any multi-line or long text value — it rendered the scalar's own marker as the text. Replaced per maintainer ruling: the demo's fake `renderPreview` now round-trips the designer's own PNG export via a new `MountHandle.getPngBlob()` (full font/renderer fidelity, the same "read access" shape `getPayload()` established) and stamps a small deterministic info strip on top, so the image is still unmistakably the host's own render. Deletes the crude rasterizer. 4. Demo UX: the "Push display list" and garage-only "Add a display" buttons are one repeatable "Add a display" button — each press extends the targets list by the next fixture display; once all four are in, the button disables ("All fixture displays added"). Remove-selected and the other demo affordances are untouched. New: `MountHandle.getPngBlob()` / `DesignerHost.registerRenderSource`, wired the same way `getPayload()`/`registerPayloadSource` are (a parent-owned ref published from DesignerCanvas, now shared via the new `usePublishedCallback` hook alongside YamlPanel's flush/discard refs). Tests: yaml-panel-preview-lock-indicator, use-display-preview-geometry-clear (hook-level, fake timers), get-png-blob (full mount, stubbed 2D context), embed-targets.spec.ts updated for the consolidated button. All red-checked against pre-fix code before the fix landed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… scoping (#138) Adversarial review of the layer-1 resolver found that several of the PR's claims were only true of the helpers, not of what the user sees. Each fix below comes with a test the reviewer's own sabotage fails. Major: - A synchronous mount failure left the resolver installed. The tier is installed before any DOM exists, so it now lives under the same pristine guarantee as the container (issue #116): `mountDesigner` keeps ONE `teardown` list, unwound by `destroy()` and by any failure before the first committed frame. Retrying a doomed mount no longer stacks orphan tiers. - A host that took a call and never settled it left the referencing elements mid-load forever — no asset and no error, the one outcome issue #10 forbids. Resolver calls now time out (`HOST_ASSET_TIMEOUT_MS`, 15s) and settle as the ordinary explicit render-error, with the retry window measured from there. No AbortSignal in this layer, deliberately. Medium: - `destroy()` forgot the mount's cache but not the bytes it produced: the parsed opentype font, the core font registry entry and the CSS `@font-face` outlived it, so a second host on the page inherited the first host's file. Caches now register with `registerHostAssetEvictor` and are pruned per `(kind, name)` on dispose; an evicted font is marked unavailable so text falls back to the explicit error, not to wrong metrics. - Host-added `FontFace` objects are tracked and removed from `document.fonts` on dispose — add count equals delete count. Content-map faces pre-date this tier and keep their existing document-lifetime behaviour. - Cache keys and `hasHostSuppliedAsset` are `(kind, name)`-scoped end to end (probe, Content Manager badge, missing-asset banner, dlimg prune), so a font answer can no longer badge or retain an image of the same name. The joiner is a JSON key, which also removes the literal NUL byte that made `host-resolver.ts` a binary blob in git (the issue #125 lesson); the other literal NULs in the repo, in `useStableAssetKeys.test.ts`, are escaped too. - Docs no longer imply a self-healing TTL: a declined asset is retried on the next asset-affecting load pass that runs after the 30s window — there is no background timer and nothing wakes the designer. Minor: - Two-mount semantics are pinned: most-recent-serves for new resolutions, and an asset either live mount was supplied stays recognised, so a second mount appearing cannot prune an image the first is painting. Docs state the lossy edge. - A host URL answer is fetched to an `ArrayBuffer` instead of interpolated into a CSS `url()`, so a media route serving `logo (1).ttf` loads instead of silently failing. - Badge wording matches the label the code renders (`Host`, `Missing`). - The demo host font is emitted from `src/assets/fonts/rbm.ttf` at build time (`tools/demoHostAssets.ts`) rather than committed as a second 165 KB copy. - `AssetKind` is exported from `src/embed`, so a host adapter can name the discriminator it switches on. Coverage added for the properties the reviewer disproved: tier order (an uploaded or bundled asset never reaches the resolver), first-frame install position, and standalone parity as a microtask probe rather than a helper's return value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ayer 1) (#144) * feat(embed): host asset resolver for payload fonts and images (#138 layer 1) Hand-written drawcustom payloads name fonts and images the way the host names them (`Ubuntu-R.ttf`, `logo.png` — the integration's own font/media directories). The designer had no way to resolve those, so such references broke in embed mode. `MountOptions.resolveAsset(kind, name)` closes the gap as the LAST resolution tier, behind the local content map and the bundled assets (ADR-002 amended): - `src/core/assets/host-resolver.ts` — install/dispose per mount, per-mount cache keyed by (kind, name), in-flight de-duplication, and a synchronous "already supplied" probe for the render-time code paths. - The three asset loaders (opentype, CSS font-face, dlimg images) consult the tier only after local resolution fails, and only when a resolver is installed — with none, they take exactly their previous code path. - Anything the host cannot supply (null, rejection, out-of-contract value) settles as the existing explicit render-error state, naming the asset and the host. No substituted font, no silent gap. - Cache policy: supplied assets for the mount's lifetime (loading re-runs on every payload asset-key change and must not cost a round trip per edit); unsupplied ones for 30s and then retried, so a host store that comes back needs no remount. - Content Manager badges a host-supplied key HOST instead of MISSING, and the "Missing local assets" banner no longer fires for it. - Search paths stay host-side: the contract is name -> asset, with no domain vocabulary in the designer (ADR-018). The demo host serves one font (URL answer) and one image (Blob answer) from demo/assets/ and declines a third name on purpose, so the round trip and the error state are both visible on the page and in e2e. Layer 2 (asset catalog + select-or-upload) stays deferred. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embed): harden the host asset resolver — timeouts, eviction, kind scoping (#138) Adversarial review of the layer-1 resolver found that several of the PR's claims were only true of the helpers, not of what the user sees. Each fix below comes with a test the reviewer's own sabotage fails. Major: - A synchronous mount failure left the resolver installed. The tier is installed before any DOM exists, so it now lives under the same pristine guarantee as the container (issue #116): `mountDesigner` keeps ONE `teardown` list, unwound by `destroy()` and by any failure before the first committed frame. Retrying a doomed mount no longer stacks orphan tiers. - A host that took a call and never settled it left the referencing elements mid-load forever — no asset and no error, the one outcome issue #10 forbids. Resolver calls now time out (`HOST_ASSET_TIMEOUT_MS`, 15s) and settle as the ordinary explicit render-error, with the retry window measured from there. No AbortSignal in this layer, deliberately. Medium: - `destroy()` forgot the mount's cache but not the bytes it produced: the parsed opentype font, the core font registry entry and the CSS `@font-face` outlived it, so a second host on the page inherited the first host's file. Caches now register with `registerHostAssetEvictor` and are pruned per `(kind, name)` on dispose; an evicted font is marked unavailable so text falls back to the explicit error, not to wrong metrics. - Host-added `FontFace` objects are tracked and removed from `document.fonts` on dispose — add count equals delete count. Content-map faces pre-date this tier and keep their existing document-lifetime behaviour. - Cache keys and `hasHostSuppliedAsset` are `(kind, name)`-scoped end to end (probe, Content Manager badge, missing-asset banner, dlimg prune), so a font answer can no longer badge or retain an image of the same name. The joiner is a JSON key, which also removes the literal NUL byte that made `host-resolver.ts` a binary blob in git (the issue #125 lesson); the other literal NULs in the repo, in `useStableAssetKeys.test.ts`, are escaped too. - Docs no longer imply a self-healing TTL: a declined asset is retried on the next asset-affecting load pass that runs after the 30s window — there is no background timer and nothing wakes the designer. Minor: - Two-mount semantics are pinned: most-recent-serves for new resolutions, and an asset either live mount was supplied stays recognised, so a second mount appearing cannot prune an image the first is painting. Docs state the lossy edge. - A host URL answer is fetched to an `ArrayBuffer` instead of interpolated into a CSS `url()`, so a media route serving `logo (1).ttf` loads instead of silently failing. - Badge wording matches the label the code renders (`Host`, `Missing`). - The demo host font is emitted from `src/assets/fonts/rbm.ttf` at build time (`tools/demoHostAssets.ts`) rather than committed as a second 165 KB copy. - `AssetKind` is exported from `src/embed`, so a host adapter can name the discriminator it switches on. Coverage added for the properties the reviewer disproved: tier order (an uploaded or bundled asset never reaches the resolver), first-frame install position, and standalone parity as a microtask probe rather than a helper's return value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds
getPayload()toMountHandle(bothmount()andmountStandaloneApp()) so a host can read the designer's current drawcustom YAML payload directly — the same stringonSaveRequestwould receive if the user hit Save right now — instead of DOM-scraping the shadow root for the Save button and simulating a click. That scrape is what the upstream OpenDisplay HA integration (PR #100) does today, and it hangs silently whenever a YAML error has disabled the Save button (see ADR-018, gap 1).Closes #104.
Semantics (documented in
docs/embedding.md)getPayload()reusesonSaveRequest's own serializer path — no second serializer to drift out of sync — and resolves three edge cases so it can never disagree with what Save would send:mount()/mountStandaloneApp()returns, before React has committed/run effects): returns the bootstrap payload. Never throws, never returnsundefined.getPayload()forces that flush before reading — a real Save click always blurs the editor first, so this matches exactly what a click would send instead of lagging behind it.Implementation
src/embed/host.ts—DesignerHost.registerPayloadSource?mirrorsregisterPushTarget, but read-direction (ADR-018 seam grammar: typed read of designer output, no bidirectional shared state).src/embed/mount.tsx— apayloadSourceclosure variable (parallel to the existing push queue) backsMountHandle.getPayload(); falls back toserializeYamlPayload(bootstrap?.elements ?? [])before anything registers.src/ui/hooks/useProjectState.ts— exposesgetElementsSnapshot(), a stable accessor onto the hook's own synchronously-updatedelementsRef(updated before the debounce-triggeredsetElements, unlike a render-derived ref).src/ui/components/YamlPanel.tsx— exposes its liveflushYamlElementsSyncthrough an optionalflushPendingRefso the registered payload getter can force the same flush a real blur/timeout would trigger.src/ui/App.tsx— wires the ref, registershost.registerPayloadSource(() => { flush(); return serializeYamlPayload(getElementsSnapshot()) }).src/embed/types.ts—MountHandle.getPayload(): string, documented.Additive only: no existing
MountOptions/MountHandlemember changes shape or behavior.Tests (TDD red-first)
tests/embed/get-payload.test.tsx— confirmed all 7 cases failed withgetPayload is not a functionbefore implementing, then green after:onSaveRequest's payload via a real Save click.setPayload()push once registered.act()).undefinedfor a standalone handle called immediately aftermountStandaloneApp().getPayload()works once loaded.getPayload()never lags a Save click.Gate
One
tests/embed/standalone-host.test.tsxflake was observed on the first full-suitenpm testrun under this session's CPU contention (documented, pre-existing class perdocs/testing.md's CI-runner-timeout note, commit4ce984e) — reproduced-clean in isolation and on a clean re-run of the full suite; unrelated to this diff, not introduced by it.🤖 Generated with Claude Code
Verified
setPayload(last-wins).6ae6226.f3a3892: integration testing against PR fix(embed): register the host push target at commit, not after paint #117's branch (registerPushTarget -> useLayoutEffect) found a commit-window defect that will manifest once both merge — registerPayloadSource stayed a passive useEffect, so a host push could apply live while getPayload() still read the stale bootstrap. Fixed by co-timing registerPayloadSource (and YamlPanel's usePublishedCallback) onto useLayoutEffect; red-before/green-after ordering-assertion test plus a self-contained MutationObserver regression guard for after the fix(embed): register the host push target at commit, not after paint #117 rebase. Full gate green (npm test, lint, build, build:lib, test:e2e).Maintainer validation
feat:— merging this (or feat(release): publish to npm via auto-release; add sha256 + NOTICE + THIRD_PARTY.md release assets #113) cuts v1.1.0.