Skip to content

fix: 0.27.9 pre-release QA findings (HTML root fallback, refresh version diff, compact refresh, threading cycles) - #1405

Merged
backnotprop merged 17 commits into
mainfrom
fix/0.27.9-qa-findings
Aug 27, 2026
Merged

fix: 0.27.9 pre-release QA findings (HTML root fallback, refresh version diff, compact refresh, threading cycles)#1405
backnotprop merged 17 commits into
mainfrom
fix/0.27.9-qa-findings

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Fixes the confirmed findings from the 0.27.9 pre-release QA. One commit per item; both server runtimes (Bun packages/server and the hand-maintained Pi mirror in apps/pi-extension/server) are updated together.

1. HTML root unreadable

readRootHtml guarded only existence and the 2 MB cap, so a root replaced by a directory or stripped of read permission made the read throw: on Pi the rejection was unhandled and /api/plan never answered (the tab hung on reload), on Bun /api/plan returned 500. Both runtimes now catch the read error and fall back to the startup snapshot exactly like the missing-file path, with the same version-diff fields. Tests on both sides cover a root replaced by a directory and, where the runner is not root, a chmod 000 file.

2. Version diff lost after Refresh and reload

Once the served root bytes differed from the startup snapshot, both servers omitted the version-diff fields and applyRefreshedHtml nulled them client-side, so the "Show changes vs previous version" toggle vanished for the rest of the session the moment a reviewer refreshed. Per the maintainer ruling the diff is recomputed rather than omitted: previousPlan and versionInfo keep describing the saved baseline, and diffCurrent and diffHtml are recomputed against the served bytes (htmlDiff is pure; a GET never writes history). For the in-app Refresh, /api/doc carries the same recomputed fields when it serves the root document only (rootHtmlVersionDiff option on both doc handlers; linked docs unchanged), the published useHtmlRefresh passes the whole snapshot to onSnapshot as a second argument (existing callers are unaffected), and applyRefreshedHtml sets the fields instead of nulling them while still resetting isPlanDiffActive. The #1232 server tests on both runtimes now assert the recomputed diff and its absence for a sibling document; a DOM test asserts the toggle survives a refresh and renders the recomputed diff. CLAUDE.md's annotate /api/plan row documents the contract.

3. Compact touch shell has no Refresh

The Options menu had equivalents for the pen and the eye but none for Refresh, while the header comment claimed parity. The menu now offers "Refresh from disk", gated on the same canRefresh as the header button and disabled while a refresh is in flight, and the comment names all three actions. A DOM test at a coarse-pointer compact viewport asserts the action is present and that the refreshed bytes reach the viewer. HtmlSurfaceActions.tsx was dead (only its own test consumed it), so it and its test are deleted, PLANNOTATOR_HTML_REFRESH_LABELS moved into AppHeader.tsx, and the CI DOM list drops the removed test file.

4. HtmlSurfaceControls contract

The Refresh button rendered only inside the onToggleTools branch, so a host passing canRefresh and onRefresh without the eye got no Refresh. The refresh and the eye still share one group left of the pen, but each renders on its own terms. Tests cover refresh without the eye, refresh alone, and the all-absent case.

5. inReplyTo export cycles

exportAnnotations dropped annotations forming an inReplyTo cycle while the header count still included them. The threading rule now lives once in @plannotator/core/annotation-threads (resolveReplyParents): an annotation is a reply only when its target is a different annotation in the list and the parent chain never returns to it; roots, orphans, self-references and every cycle member are emitted as roots in original order, so nothing is dropped and the count equals what is emitted. The export and AnnotationPanel.threadReplies both apply it. Both runtimes' PATCH /api/external-annotations additionally validate inReplyTo (validateReplyTarget: must name an existing, different annotation and must not close a cycle, else 400), so the invalid state cannot be created there. Tests cover the helper, the export, the panel, and ingest on Bun and Pi; vendor.sh vendors the new core module and packages/core/package.json exports it.

6. Docs

CLAUDE.md/AGENTS.md gains an HTML Refresh paragraph under the Annotation System, notes the shared threading rule and the PATCH validation, and no longer points the WebMCP section at an untracked design file. The marketing annotate page documents Refresh in its HTML section, and the WebMCP reference lists the comment_only_surface nudge that nudges.ts already emits.

Verification

bun run typecheck; bun test packages/ui packages/editor packages/core packages/server/annotate.test.ts (1087 pass); the Pi annotate suites plus the new Pi external-annotations test (31 pass); DOM_TESTS=1 on the HTML-surface suites (srcdoc, htmlPinpointProtocol, htmlLiveProtocol, App.htmlChrome, HtmlSurfaceControls, useHtmlRefresh, useSharing.contentRevision, the AnnotationPanel tests, App.archiveReadOnly; 216 pass); build:review and build:hook; guides.show build:viewer, check:manifest (in sync, no manifest change) and check:budgets. All server runs were sandboxed under a scratch PLANNOTATOR_DATA_DIR.

AI-assisted (Claude) under maintainer direction.

…s unreadable

readRootHtml guarded only existence and the 2 MB cap, so a root replaced by a
directory or stripped of read permission made the read throw: the Pi server
left /api/plan unanswered (the tab hung on reload) and the Bun server answered
500. Both runtimes now treat an unreadable root exactly like a missing one and
serve the startup snapshot with its version-diff fields. Tests on both sides
cover the directory swap and, where the runner is not root, a chmod 000 file.
Once the served root bytes differed from the startup snapshot, both servers
omitted the version-diff fields and applyRefreshedHtml nulled them client-side,
so the "Show changes vs previous version" toggle vanished for the rest of the
session the moment a reviewer refreshed: exactly when an agent had just edited
the file. previousPlan/versionInfo describe the saved baseline, which stays the
correct previous version, so both runtimes now keep them and recompute
diffCurrent/diffHtml against the served bytes (htmlDiff is pure; a GET never
writes history). /api/doc carries the same recomputed fields when it serves the
root document (rootHtmlVersionDiff, root only; linked docs unchanged), the
published useHtmlRefresh passes the whole snapshot to onSnapshot, and
applyRefreshedHtml sets the fields instead of nulling them while still
resetting isPlanDiffActive. Server tests on both runtimes assert the
recomputed diff (and its absence for a sibling document); a DOM test asserts
the toggle survives a refresh. CLAUDE.md documents the /api/plan contract.
HtmlSurfaceControls renders nothing on compact and the Options menu had
equivalents for the pen and the eye but none for Refresh, while the header
comment claimed the compact shell offered the same actions. The menu now has a
"Refresh from disk" entry gated on the same canRefresh as the header button
(local HTML files only; never URL or live-app sessions) and disabled while a
refresh is in flight, and the comment names all three actions. A DOM test at a
coarse-pointer compact viewport asserts the action and that the refreshed
bytes reach the viewer.

HtmlSurfaceActions.tsx was dead (only its own test consumed it; the header
renders the published HtmlSurfaceControls directly and the parity sign-off
happened in the browser), so it and its test are removed and
PLANNOTATOR_HTML_REFRESH_LABELS now lives in AppHeader, the only importer. The
CI DOM list drops the deleted test file.
The Refresh button only rendered inside the onToggleTools branch, so a host
that passed canRefresh and onRefresh without a tools toggle got no Refresh,
contradicting the documented props. The refresh and the eye still share one
group left of the pen, but each now renders on its own terms; the test covers
refresh without the eye, refresh alone, and the all-absent case.
…es at ingest

exportAnnotations dropped every annotation that formed an inReplyTo cycle
(reachable through PATCH /api/external-annotations, which merges arbitrary
fields) while the header count still included it. The threading rule now
lives once in @plannotator/core/annotation-threads (resolveReplyParents): an
annotation is a reply only when its target is a different annotation in the
list and the parent chain never returns to it; roots, orphans,
self-references and every cycle member are emitted as roots in original
order, so nothing is dropped and the count equals what is emitted. The
export and AnnotationPanel.threadReplies both apply it. Both runtimes'
PATCH handlers additionally validate inReplyTo (validateReplyTarget): it must
name an existing, different annotation and must not close a cycle, otherwise
400, so the invalid state cannot be created there. Tests cover the helper,
the export, the panel, and ingest on Bun and Pi; vendor.sh vendors the new
core module.
…n pointer

CLAUDE.md/AGENTS.md gains an HTML Refresh paragraph under the Annotation
System (what Refresh does, which anchors survive, the Unanchored chip, the
recomputed version diff, the compact-shell menu action, and that URL and
live-app sessions have no Refresh), notes the shared inReplyTo threading rule
and the PATCH validation, and stops pointing the WebMCP section at an
untracked design file. The marketing annotate page documents Refresh in its
HTML section, and the WebMCP reference lists the comment_only_surface nudge
that packages/ui/webmcp/nudges.ts already emits.
resolveReplyParents walked every chain to its root without memoization, so a
5,000-deep inReplyTo chain cost 12.5 million steps (431 ms; 20,000 took
9.8 s), and the panel re-ran it on every render. Each id is now classified
once with path compression, cycle members are still roots and the run-in
into a cycle stays replies, and the new resolveThreadRootTimestamps gives
the panel its thread ordering from one linear pass. A 5,000-chain timing
test guards both.
threadTs walked the reply chain with all.find per hop inside the sort
comparator, O(n^2 log n): 2,000 threaded comments took 4.5 s to sort per
render and 5,000 over a minute, and POST /api/external-annotations has no
depth or count limit. Thread timestamps are now resolved once per render
through the shared linear helper and the comparator reads the map;
threadReplies emits iteratively so a deep chain costs no stack. A
5,000-chain test asserts the ordering completes well under 100 ms.
replyBlock re-filtered and re-sorted the whole annotation list at every
nesting level and indented without bound, so 10,000 replies produced 100 MiB
of markdown on /api/feedback. Children are grouped once, the thread is
emitted from an explicit stack into an array of parts, and the nesting
indent is capped at eight levels, so a 5,000-reply chain exports in linear
size and time (tested).
The readRootHtml fallback was silent on both runtimes and its unreadable
reason was never consumed, so a genuine read bug could hide behind the
startup snapshot. Both servers now console.warn once per process with the
path and the error message, keeping the fallback itself. The Bun
directory-swap test comment is corrected: Bun.file(dir).exists() is false,
so that case guards the Pi mirror while chmod 000 is the Bun guard; both
tests assert the single warning.
tombstones, ownHashes and ownSeqs were never evicted, so a create/delete
loop of 5,000 left all three at 5,000 for the life of the tab. Tombstones
are now capped at MAX_TOMBSTONES (2,000, oldest first) and the ownership
and agent-removal records of a forgotten id go with it; live entries keep
theirs. Tested past the cap.
The idempotency map only ever grew. DocumentToolState.rememberRequest keeps
the newest MAX_REMEMBERED_REQUESTS (500) requestIds, oldest evicted first;
a retry of a recent call is still deduplicated, and a replay of an evicted
id creates anew (tested).
mintedHtmlAnnIds was module-scoped and never cleared, so ids minted by one
HtmlViewer leaked into unrelated later instances in the same tab (the
Workspaces SPA). The set now lives in a ref per hook instance and is cleared
on unmount; the unanchored union only ever consults it against the bridge
of the instance that minted the ids. A DOM test mounts A, mints, unmounts,
mounts B and asserts B starts empty.
…eport is empty

Verifies the sweep claim that a chip from refresh 1 survives refresh 2: it
does not. The viewer asks the bridge for a complete post-restore report and
the bridge answers empty set included (pinned in htmlPinpointProtocol), and
the App replaces its chip set with each report. The DOM test restores a
drafted anchor, refreshes with a report naming it (chip), refreshes again
with an empty report, and asserts the chip is gone. No fix was needed.
A 10,000-annotation burst yielded an 89 KB annotations_new nudge. The
annotations_new, replies_new and annotations_removed nudges now list at
most MAX_NUDGE_IDS (100) ids and the message says how many more were left
out, the way other_document_active is capped.
reveal { path } waiters and their commit timeouts had no unmount cleanup,
so a timeout could fire into a dead hook. Unmount now fails every pending
waiter and clears its timer.
Mermaid and Graphviz memoize one runtime per module, so a chunk import
failure fails every block together, but each Retry bumped only the clicked
block's own token and its siblings stayed on their error panels after the
runtime recovered. A module-level retry epoch (utils/runtimeRetry) notifies
every subscribed block whose failure was the runtime import; healthy blocks
and syntax errors are left alone. Tested with two instances per runtime.
@backnotprop
backnotprop merged commit c2950e7 into main Aug 27, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant