fix(stream): resume cursor excludes parser pending tail on cursorless markdown rebuild
Greptile P1 follow-up on the fade-row rebuild: when a cursorless live markdown row adopts the incremental node's cloned DOM, that DOM lags the source text by the streaming parser's held-back tail (pending/text buffers). Keeping the full source-length resume cursor made the next reconciliation append nothing, so the pending characters stayed missing from the live row until settlement. Read the held-back length from the parser bound on the candidate body (_smdBindParserIdentity) and trim the source-space cursor by exactly that length, so the cursor matches the adopted content and the next reconciliation re-appends the pending tail. Regression: rebuild while the parser holds an unrendered tail, then reconcile again — the pending text must appear in the live row before settlement.
This commit is contained in:
1 parent
34f10d2545
commit
259631e46b
2 files changed
+130
-16
No files matched your search
+23
-4
@@ -13916,11 +13916,30 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){
|
||||
// The streaming parser holds a pending tail, so the adopted DOM can lag
|
||||
// the source text. When the rendered text is a strict prefix of the
|
||||
// source (plain prose), resume the cursor from it so the held-back
|
||||
// characters arrive with the next delta instead of being dropped;
|
||||
// otherwise (markdown: rendered text diverges from source) keep the
|
||||
// full source-space cursor — the append contract stays source-space.
|
||||
// characters arrive with the next delta instead of being dropped.
|
||||
const renderedNow = String(body.textContent || '');
|
||||
if(renderedNow && nextText.startsWith(renderedNow)) resumeCursor = renderedNow;
|
||||
if(renderedNow && nextText.startsWith(renderedNow)){
|
||||
resumeCursor = renderedNow;
|
||||
}else{
|
||||
// Markdown (Greptile P1 follow-up): rendered text diverges from the
|
||||
// source, so the rendered-prefix resume above cannot apply — but the
|
||||
// cloned DOM still lags the source by the parser's held-back tail. A
|
||||
// full source-length cursor would make the next reconciliation append
|
||||
// nothing and the pending characters vanish until settlement. The
|
||||
// parser state is bound on the incremental node's body by
|
||||
// _smdBindParserIdentity (messages.js), and `pending`/`text` are its
|
||||
// unrendered source-space buffers (`text` is flushed at the end of
|
||||
// every parser_write; read defensively anyway). Trim the cursor by
|
||||
// exactly that held-back length so the cursor matches what the clone
|
||||
// actually renders and the tail re-appends with the next delta.
|
||||
const liveParser = candidateBody.__smdParser;
|
||||
const heldLen = liveParser
|
||||
? String(liveParser.pending || '').length + String(liveParser.text || '').length
|
||||
: 0;
|
||||
if(heldLen > 0 && heldLen <= nextText.length){
|
||||
resumeCursor = nextText.slice(0, nextText.length - heldLen);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
body.textContent = '';
|
||||
_appendTransparentFadeText(body, nextText);
|
||||
|
||||
@@ -358,14 +358,20 @@ process.stdout.write(JSON.stringify({
|
||||
# way production wires it (window.__streamFadeMuteRenderedPrefix).
|
||||
_REBUILD_PRELUDE = """
|
||||
const messagesSrc = readFileSync(process.env.MESSAGES_JS_PATH, 'utf8');
|
||||
(0, eval)(extractConst('_TRANSPARENT_FADE_BLOCK_TAGS'));
|
||||
(0, eval)(extractFunc('_transparentFadeAppendTarget'));
|
||||
(0, eval)(extractFunc('_bindTransparentFadeCleanup'));
|
||||
(0, eval)(extractFunc('_appendTransparentFadeText'));
|
||||
(0, eval)(extractFunc('_transparentLiveRowAttributePairs'));
|
||||
(0, eval)(extractFunc('_transparentLiveRowInteractiveState'));
|
||||
(0, eval)(extractFunc('_rehydrateTransparentLiveRow'));
|
||||
(0, eval)(extractFunc('_refreshTransparentFadeProseRow'));
|
||||
// Single combined eval: sloppy-mode indirect eval hoists function declarations
|
||||
// to the global object but discards top-level `const` bindings after the call,
|
||||
// so _TRANSPARENT_FADE_BLOCK_TAGS must be evaluated in the SAME eval as the
|
||||
// functions that close over it (_transparentFadeAppendTarget).
|
||||
(0, eval)([
|
||||
extractConst('_TRANSPARENT_FADE_BLOCK_TAGS'),
|
||||
extractFunc('_transparentFadeAppendTarget'),
|
||||
extractFunc('_bindTransparentFadeCleanup'),
|
||||
extractFunc('_appendTransparentFadeText'),
|
||||
extractFunc('_transparentLiveRowAttributePairs'),
|
||||
extractFunc('_transparentLiveRowInteractiveState'),
|
||||
extractFunc('_rehydrateTransparentLiveRow'),
|
||||
extractFunc('_refreshTransparentFadeProseRow'),
|
||||
].join('\\n'));
|
||||
(0, eval)(extractFuncFrom(messagesSrc, '_streamFadeMuteRenderedPrefix'));
|
||||
globalThis.window.__streamFadeMuteRenderedPrefix = globalThis._streamFadeMuteRenderedPrefix;
|
||||
if(typeof window.__streamFadeMuteRenderedPrefix !== 'function'){
|
||||
@@ -508,12 +514,16 @@ smd.parser_write(parser, SOURCE.slice(0, 10));
|
||||
const prevRendered = body.textContent;
|
||||
|
||||
// Candidate: the incremental node fed the FULL source through the real
|
||||
// streaming-markdown parser (still live, deliberately not ended).
|
||||
// streaming-markdown parser (still live, deliberately not ended). Production
|
||||
// binds the parser on the body via _smdBindParserIdentity (messages.js), and
|
||||
// the cursor fix reads the held-back tail from that binding.
|
||||
const candidate = makeRow(SOURCE);
|
||||
const candidateBody = candidate.querySelector('.msg-body');
|
||||
const candidateParser = smd.parser(smd.default_renderer(candidateBody));
|
||||
smd.parser_write(candidateParser, SOURCE);
|
||||
candidateBody.__smdParser = candidateParser;
|
||||
const candidateRendered = candidateBody.textContent;
|
||||
const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || '');
|
||||
|
||||
_refreshTransparentFadeProseRow(existing, candidate, null);
|
||||
|
||||
@@ -523,6 +533,7 @@ const strong = findTag(refreshedBody, 'STRONG');
|
||||
process.stdout.write(JSON.stringify({
|
||||
prevRendered,
|
||||
candidateRendered,
|
||||
pendingTail,
|
||||
finalText: refreshedBody.textContent,
|
||||
hasLink: !!link,
|
||||
linkText: link ? link.textContent : null,
|
||||
@@ -546,6 +557,90 @@ process.stdout.write(JSON.stringify({
|
||||
assert "**" not in data["finalText"]
|
||||
assert "](" not in data["finalText"]
|
||||
assert data["finalText"] == data["candidateRendered"]
|
||||
# Rendered text diverges from source here, so the cursor must stay in
|
||||
# source space (the append contract of the cursor branch).
|
||||
assert data["cursor"] == md_source
|
||||
# Rendered text diverges from source here, so the cursor stays in source
|
||||
# space — but trimmed by the parser's held-back tail (Greptile P1
|
||||
# follow-up), so it matches what the adopted DOM actually renders.
|
||||
assert data["pendingTail"] == "."
|
||||
assert data["cursor"] == md_source[: -len(data["pendingTail"])]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_live_prose_rebuild_cursor_excludes_parser_pending_tail():
|
||||
"""Greptile P1 follow-up: on a cursorless markdown rebuild the adopted DOM
|
||||
lags the source by the parser's pending tail. The resume cursor must match
|
||||
the adopted content — not the full source length — so the next
|
||||
reconciliation re-appends the held-back text before settlement."""
|
||||
md_source = "Voici **un** [lien](https://example.com) super."
|
||||
script = (
|
||||
_HARNESS
|
||||
+ _REBUILD_PRELUDE
|
||||
+ """
|
||||
const smd = await import(process.env.SMD_PATH);
|
||||
|
||||
const SOURCE = %s;
|
||||
|
||||
// Existing row: cursorless, the parser has only seen a prefix of the source.
|
||||
const existing = makeRow(undefined);
|
||||
delete existing.dataset.rawText;
|
||||
const body = existing.querySelector('.msg-body');
|
||||
const parser = smd.parser(smd.default_renderer(body));
|
||||
smd.parser_write(parser, SOURCE.slice(0, 8));
|
||||
|
||||
// Candidate: the persistent incremental node, full source written, parser
|
||||
// still live (not ended) and bound on the body exactly like
|
||||
// _smdBindParserIdentity does in production.
|
||||
const candidate = makeRow(SOURCE);
|
||||
const candidateBody = candidate.querySelector('.msg-body');
|
||||
const candidateParser = smd.parser(smd.default_renderer(candidateBody));
|
||||
smd.parser_write(candidateParser, SOURCE);
|
||||
candidateBody.__smdParser = candidateParser;
|
||||
const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || '');
|
||||
const candidateRendered = candidateBody.textContent;
|
||||
|
||||
// First reconciliation: cursorless rebuild adopts the cloned parsed DOM.
|
||||
_refreshTransparentFadeProseRow(existing, candidate, null);
|
||||
const afterRebuildText = existing.querySelector('.msg-body').textContent;
|
||||
const afterRebuildCursor = existing.getAttribute('data-stream-fade-text');
|
||||
|
||||
// Next reconciliation of the SAME live row (no new source text needed — the
|
||||
// scene re-renders every frame). With a correct cursor this appends the
|
||||
// held-back tail; with a full source-length cursor it appends nothing and the
|
||||
// pending text stays missing until settlement.
|
||||
_refreshTransparentFadeProseRow(existing, candidate, null);
|
||||
const afterResumeBody = existing.querySelector('.msg-body');
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
pendingTail,
|
||||
candidateRendered,
|
||||
afterRebuildText,
|
||||
afterRebuildCursor,
|
||||
afterResumeText: afterResumeBody.textContent,
|
||||
afterResumeCursor: existing.getAttribute('data-stream-fade-text'),
|
||||
}));
|
||||
"""
|
||||
% json.dumps(md_source)
|
||||
)
|
||||
data = _run_node_module(script)
|
||||
|
||||
# Precondition: the live parser really holds a non-empty pending tail, and
|
||||
# the adopted DOM therefore lags the source text.
|
||||
assert data["pendingTail"], "expected the streaming parser to hold a pending tail"
|
||||
assert data["pendingTail"] == "."
|
||||
assert data["afterRebuildText"] == data["candidateRendered"]
|
||||
assert not data["afterRebuildText"].endswith(data["pendingTail"])
|
||||
|
||||
# The fix under test: the resume cursor matches the adopted content, i.e.
|
||||
# source minus the parser's held-back tail — not the full source length.
|
||||
assert data["afterRebuildCursor"] == md_source[: -len(data["pendingTail"])], (
|
||||
"resumeCursor must exclude the parser's pending tail; got "
|
||||
f"{data['afterRebuildCursor']!r}"
|
||||
)
|
||||
|
||||
# Regression: the next reconciliation re-appends the pending text, so it is
|
||||
# visible in the live row BEFORE settlement.
|
||||
assert data["afterResumeText"] == data["candidateRendered"] + data["pendingTail"], (
|
||||
"pending text never reached the live row: "
|
||||
f"{data['afterResumeText']!r}"
|
||||
)
|
||||
assert data["afterResumeText"].endswith("super.")
|
||||
assert data["afterResumeCursor"] == md_source
|
||||
Reference in new issue
Block a user