Context
A new editor command will let users create a draft document from inside the editor and immediately embed/link it into the current document. Because drafts are local-only (not yet shared on the network), publishing a parent document while it still contains links/embeds to local drafts produces broken references for every reader.
We need a guard that runs at publish time, detects any reference to a local draft in the current document, and blocks the publish with a clear inline message in the publish popover. The user is expected to fix the offending links manually (publish the referenced drafts first, or remove the references) and try again.
Decisions locked
Critical files
frontend/packages/shared/src/models/document-machine.ts— add guard, new event/context shape, branching transition.frontend/packages/shared/src/content.ts— reuseextractAllContentRefs(already extracts every hm:// ref with parsedrefIdfrom blocks + annotations + children). Add a tiny helperfindDraftReferences(blocks)next to it.frontend/apps/desktop/src/components/editing-toolbar.tsx— atpublishNow(line 397), read blocks viauseEditorHandlersRef().current?.getCurrentBlocks()and include them in thepublish.startevent payload. Render the new error inPublishPopoverBody.Tests:
frontend/packages/shared/src/__tests__/content-refs.test.ts(extend), newfrontend/packages/shared/src/models/__tests__/document-machine.publish-guard.test.tsfor the transition, and a focused unit test forPublishPopoverBodyif practical.
Implementation steps
1. Helper: findDraftReferences
In frontend/packages/shared/src/content.ts, next to extractAllContentRefs:
export function findDraftReferences(blocks: HMBlockNode[]): RefDefinition[] {
return extractAllContentRefs(blocks).filter((ref) =>
ref.refId.path?.some((seg) => seg.startsWith('-'))
)
}Rationale: extractAllContentRefs already handles every URL-bearing surface (embed, link block, button, image/video/file, query includes[].space, inline link/embed annotations) and recurses children. We only need the draft-segment filter.
2. Document machine changes (document-machine.ts)
Add to context shape (near line 85):
publishBlocked: {reason: 'draft-links'} | nullInitial value null (extend the error: null init around line 585).
Extend the publish.start event type so it carries blocks:
| {type: 'publish.start'; pathOverride?: string[]; blocks?: HMBlockNode[]}Add a guard in setup({guards: {...}}):
hasDraftReferences: ({event}) => {
if (event.type !== 'publish.start') return false
return findDraftReferences(event.blocks ?? []).length > 0
}Add a clearPublishBlocked action:
clearPublishBlocked: assign({publishBlocked: null})Replace the publish.start transition (current line 764–768) with an ordered conditional array (XState v5 on accepts array of transitions; first matching guard wins):
'publish.start': [
{
guard: 'hasDraftReferences',
actions: [assign({publishBlocked: () => ({reason: 'draft-links'})})],
// No target — stay in editing.draft.idle, just assign context.
},
{
target: '#DocumentLifecycle.publishing',
guard: 'hasDraftId',
actions: ['clearPublishBlocked', 'setPathOverrideFromEvent'],
},
],Also clear publishBlocked on change and reset.content actions in editing.draft.idle (and in editing.draft.changed) so the error disappears as soon as the user edits.
Add a dismiss event for explicit close:
'publish.blocked.dismiss': {
actions: ['clearPublishBlocked'],
}3. Selector
In frontend/packages/shared/src/models/document-machine.ts (or wherever selectors live — selectDraftId already exists nearby), add:
export const selectPublishBlocked = (state: DocumentMachineSnapshot) => state.context.publishBlocked4. UI wiring (editing-toolbar.tsx)
In publishNow (line 397):
const handlersRef = useEditorHandlersRef()
const publishNow = (pathOverride?: string[]) => {
popoverState.onOpenChange(false)
setHasTriggeredPublish()
send({type: 'edit.start'})
const blocks = handlersRef.current?.getCurrentBlocks() ?? []
send({type: 'publish.start', pathOverride, blocks})
}Note: do NOT close the popover before sending the event. Reorder so the popover stays open if the publish is blocked:
const publishNow = (pathOverride?: string[]) => {
setHasTriggeredPublish()
send({type: 'edit.start'})
const blocks = handlersRef.current?.getCurrentBlocks() ?? []
send({type: 'publish.start', pathOverride, blocks})
// Caller side-effect: popover stays open if context.publishBlocked got set.
}In PublishPopoverBody (line 124), read the blocked state and render an inline error block above the publish button when publishBlocked is set. On onClose, dispatch publish.blocked.dismiss so the next popover open starts clean.
const publishBlocked = useDocumentSelector(selectPublishBlocked)
// ...
{publishBlocked?.reason === 'draft-links' && (
<div role="alert" className="...error styling...">
Publish the referenced drafts first before publishing this document.
</div>
)}Disable the publish button while publishBlocked is set, so the user must either edit content (auto-clears) or close.
5. Tests
content-refs.test.ts: extend withfindDraftReferencescases:empty content →
[]embed link
hm://uid/-abc1234567→ 1 hitnested child block with embed to draft → 1 hit
inline embed annotation to draft → 1 hit
query block
includes[].space = 'hm://uid/-draftSeg'→ 1 hitpublished-only refs (
hm://uid/parent/child) →[]
document-machine.publish-guard.test.ts:send
publish.startwith draft-link blocks → stays inediting.draft.idle,publishBlocked.reason === 'draft-links'send
publish.startwith clean blocks → transitions topublishing.inProgress,publishBlocked === nullsend
changeafter a block →publishBlocked === nullsend
publish.blocked.dismiss→publishBlocked === null
PublishPopoverBodytest (or a small RTL test infrontend/apps/desktop): render with mocked snapshot wherepublishBlocked.reason === 'draft-links'→ asserts the error string is visible, publish button disabled.
Verification
pnpm --filter @shm/shared test— covers helper + machine transition tests.pnpm --filter @shm/desktop test:unit— covers popover render test.pnpm typecheck— confirm new event payload + context shape propagate cleanly.Manual desktop run:
Start desktop:
pnpm --filter @shm/desktop dev.In an existing doc, create an embed pointing to a draft (path with
-segment).Click publish → popover shows inline error, publish button disabled.
Remove the offending embed → popover error clears, publish button enables.
Publish succeeds.
Edge: create a fresh doc with no draft refs → publish flow unchanged.
Unresolved / future work
Cascading publish (one-click publish of referenced drafts first) — explicitly out of scope.
Auto-remove draft links — out of scope.
Block-level highlighting / scroll-to — out of scope.
The new "create document" editor command itself (creates draft + inserts embed) — separate feature; this plan only covers the guard required to make that feature safe to publish around.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime