diff --git a/src/Memoizer.ts b/src/Memoizer.ts index 81c2adfc023760a02149c449b4080a0273da37e0..558e52497d27a2008f60836c64dd83e120f24fe6 100644 --- a/src/Memoizer.ts +++ b/src/Memoizer.ts @@ -182,7 +182,7 @@ export class Memoizer { i !== end; i += direction ) { - let previousIndex: number = -1; + let previousIndex: number = -1; if (i < blockStart) { previousIndex = i; } else if (i >= nextMiddleEnd) { diff --git a/src/Predict.ts b/src/Predict.ts index 9658055c2f9e800df9c55931615e92181e0dbff3..61858d873b246a6d6da7031d8e88dbade574425d 100644 --- a/src/Predict.ts +++ b/src/Predict.ts @@ -50,10 +50,8 @@ function parseTail(tail: string): TailState { pendingDelim: null, pendingHtml: null, }; - for (let i = 0; i < tail.length; i++) { const char = tail[i]!; - if (char === "\n" && tail[i - 1] === "\n") { resetParagraphState(state); if (!state.exclusive) state.commitIndex = i + 1; @@ -65,31 +63,25 @@ function parseTail(tail: string): TailState { i = exclusiveEnd - 1; continue; } - if (state.exclusive) continue; if (inLinkUrlMode(state)) { updateLink(state, i, char); continue; } - const htmlEnd = consumeHtml(state, tail, i); if (htmlEnd > i) { i = htmlEnd - 1; continue; } - const delimEnd = consumeDelim(state, tail, i); if (delimEnd > i) { i = delimEnd - 1; continue; } - if (isEscapedAt(tail, i)) continue; - updateLink(state, i, char); } - return state; } @@ -105,7 +97,6 @@ function consumeBackticks(state: TailState, tail: string, start: number) { const length = runLengthAt(tail, start); const token = "`".repeat(length); const exclusive = state.exclusive; - if (exclusive?.kind === "fence") { if ( exclusive.token[0] === "`" && @@ -117,22 +108,18 @@ function consumeBackticks(state: TailState, tail: string, start: number) { } return start + length; } - if (exclusive?.kind === "code") { if (!isEscapedAt(tail, start) && length >= exclusive.token.length) { state.exclusive = null; } return start + length; } - if (exclusive) return start + length; if (isEscapedAt(tail, start)) return start + length; - if (length >= 3 && isLineStart(tail, start)) { state.exclusive = { kind: "fence", start, token }; return start + length; } - state.exclusive = { kind: "code", start, token }; return start + length; } @@ -140,17 +127,14 @@ function consumeBackticks(state: TailState, tail: string, start: number) { function consumeMath(state: TailState, tail: string, start: number) { const length = runLengthAt(tail, start); const exclusive = state.exclusive; - if (exclusive?.kind === "math") { if (!isEscapedAt(tail, start) && length >= exclusive.token.length) { state.exclusive = null; } return start + length; } - if (exclusive) return start + length; if (isEscapedAt(tail, start)) return start + length; - state.exclusive = { kind: "math", start, @@ -162,27 +146,22 @@ function consumeMath(state: TailState, tail: string, start: number) { function consumeTildes(state: TailState, tail: string, start: number) { const length = runLengthAt(tail, start); const exclusive = state.exclusive; - if (exclusive?.kind === "fence" && exclusive.token[0] === "~") { if (isLineStart(tail, start) && !isEscapedAt(tail, start) && length >= exclusive.token.length) { state.exclusive = null; } return start + length; } - if (exclusive) return start; if (length < 3 || !isLineStart(tail, start) || isEscapedAt(tail, start)) return start; - state.exclusive = { kind: "fence", start, token: "~".repeat(length) }; return start + length; } function consumeHtml(state: TailState, tail: string, start: number) { if (tail[start] !== "<" || isEscapedAt(tail, start)) return start; - const next = tail[start + 1]; if (next !== undefined && !isAsciiLetter(next)) return start; - state.pendingHtml = start; for (let i = start + 1; i < tail.length; i++) { if (tail[i] === ">" || tail[i] === "\n") { @@ -190,47 +169,38 @@ function consumeHtml(state: TailState, tail: string, start: number) { return i + 1; } } - return tail.length; } function consumeDelim(state: TailState, tail: string, start: number) { const token = matchDelim(tail, start); if (!token) return start; - if (isEscapedAt(tail, start)) return start + token.length; - const existingIndex = state.delims.findLastIndex((delim) => delim.token === token); if (existingIndex !== -1) { state.delims.splice(existingIndex, 1); return start + token.length; } - if (start + token.length === tail.length) { state.pendingDelim = { start, token }; return start + token.length; } - if (!canOpenDelim(tail, start, token)) return start + token.length; - state.delims.push({ start, token }); return start + token.length; } function matchDelim(tail: string, start: number): DelimToken | undefined { const char = tail[start]; - if (char === "*") { if (tail.startsWith("***", start)) return "***"; if (tail.startsWith("**", start)) return "**"; return "*"; } - if (char === "_") { if (tail.startsWith("__", start)) return "__"; return "_"; } - if (char === "~" && tail.startsWith("~~", start)) { return "~~"; } @@ -239,19 +209,16 @@ function matchDelim(tail: string, start: number): DelimToken | undefined { function canOpenDelim(tail: string, start: number, token: DelimToken) { const next = tail[start + token.length]; if (!next || /\s/.test(next)) return false; - const prev = tail[start - 1]; return !isWordChar(prev) || !isWordChar(next); } function updateLink(state: TailState, index: number, char: string) { const top = state.links.at(-1); - if (char === "[") { state.links.push({ phase: "text", start: index }); return; } - if (char === "]" && top?.phase === "text") { state.links[state.links.length - 1] = { phase: "url_wait", @@ -260,7 +227,6 @@ function updateLink(state: TailState, index: number, char: string) { }; return; } - if (char === "(" && top?.phase === "url_wait") { state.links[state.links.length - 1] = { phase: "url", @@ -270,18 +236,15 @@ function updateLink(state: TailState, index: number, char: string) { }; return; } - if (char === "(" && top?.phase === "url") { top.parenDepth += 1; return; } - if (char === ")" && top?.phase === "url") { if (top.parenDepth > 0) { top.parenDepth -= 1; return; } - state.links.pop(); } } @@ -289,10 +252,8 @@ function updateLink(state: TailState, index: number, char: string) { function resetParagraphState(state: TailState) { state.delims.length = 0; state.links.length = 0; - if (state.exclusive?.kind === "fence") return; if (state.exclusive?.kind === "math" && state.exclusive.token === "$$") return; - state.exclusive = null; } @@ -302,22 +263,18 @@ function inLinkUrlMode(state: TailState) { } function renderTail(tail: string, state: TailState) { - if (state.pendingHtml !== null) tail = tail.slice(0, state.pendingHtml); - + if (state.pendingHtml !== null) + tail = tail.slice(0, findPendingHtmlCutoff(tail, state.pendingHtml)); const link = state.links.at(-1); if (link) return renderOpenLink(tail, link); - const nestedClosers = renderNestedClosers(state); if (nestedClosers) return appendCloser(tail, nestedClosers); - const open = findLastOpen(state); if (!open) return state.pendingDelim ? tail.slice(0, state.pendingDelim.start) : tail; - if (open.kind === "delim") { if (!hasContentAfter(tail, open.start, open.token.length)) return tail.slice(0, open.start); return appendCloserBeforeTrailingInlineWhitespace(tail, open.token); } - if (!hasContentAfter(tail, open.start, open.token.length)) { return open.kind === "fence" ? tail : tail.slice(0, open.start); } @@ -330,12 +287,10 @@ function renderTail(tail: string, state: TailState) { function renderOpenLink(tail: string, state: LinkState) { const before = tail.slice(0, state.start); - if (state.phase === "text") { if (!hasContentAfter(tail, state.start, 1)) return before; return before + tail.slice(state.start + 1); } - const text = tail.slice(state.start + 1, state.textEnd); if (state.phase === "url_wait") return before + text + tail.slice(state.textEnd + 1); return before + text; @@ -343,22 +298,46 @@ function renderOpenLink(tail: string, state: LinkState) { function renderNestedClosers(state: TailState) { const closers: string[] = []; - if (state.exclusive) { if (state.exclusive.kind !== "code") return undefined; closers.push(state.exclusive.token); } - for (let i = state.delims.length - 1; i >= 0; i--) { const token = state.delims[i]!.token; if (!NESTABLE_DELIMS.has(token)) return undefined; closers.push(token); } - if (closers.length < 2) return undefined; return closers.join(""); } +function findPendingHtmlCutoff(tail: string, pendingStart: number) { + let cutoff = pendingStart; + let cursor = pendingStart; + + while (cursor > 0) { + const candidateStart = tail.lastIndexOf("<", cursor - 1); + if (candidateStart === -1) break; + if (!isCompleteHtmlTag(tail, candidateStart, cursor)) break; + cutoff = candidateStart; + cursor = candidateStart; + } + + return cutoff; +} + +function isCompleteHtmlTag(tail: string, start: number, end: number) { + if (tail[end - 1] !== ">") return false; + if (isEscapedAt(tail, start)) return false; + const next = tail[start + 1]; + if (next !== undefined && !isAsciiLetter(next)) return false; + for (let i = start + 1; i < end - 1; i++) { + const char = tail[i]; + if (char === ">" || char === "\n") return false; + } + return true; +} + function appendCloser(tail: string, closer: string) { return tail + closer.slice(overlapLength(tail, closer)); } @@ -366,7 +345,6 @@ function appendCloser(tail: string, closer: string) { function appendCloserBeforeTrailingInlineWhitespace(tail: string, closer: string) { const trailingWhitespace = tail.match(/[^\S\n]+$/)?.[0]; if (!trailingWhitespace) return appendCloser(tail, closer); - const body = tail.slice(0, -trailingWhitespace.length); return appendCloser(body, closer) + trailingWhitespace; } @@ -401,12 +379,10 @@ function isLineStart(text: string, index: number) { function isEscapedAt(text: string, index: number) { let count = 0; - for (let i = index - 1; i >= 0; i--) { if (text[i] !== "\\") break; count += 1; } - return count % 2 === 1; } diff --git a/src/hast.ts b/src/hast.ts index f2b7e352d1fe3ca349316e7888b81d0ca7a2dae7..88e3cd69145ee9fb1d994362e33fdbc9503c4a38 100644 --- a/src/hast.ts +++ b/src/hast.ts @@ -61,14 +61,31 @@ function recursiveMemoRender( const nextChildren: RenderState[] = []; const children: ReactNode[] = []; const countsByName = new Map(); - const usedKeys = new Set(previousChildren.map((child) => child.key).filter((key) => key !== undefined)); + const usedKeys = new Set( + previousChildren.map((child) => child.key).filter((key) => key !== undefined), + ); let prefixLength = 0; - for (; prefixLength < node.children.length && prefixLength < previousChildren.length; prefixLength += 1) { + for ( + ; + prefixLength < node.children.length && prefixLength < previousChildren.length; + prefixLength += 1 + ) { if (!nodeDeepEquals(node.children[prefixLength], previousChildren[prefixLength]?.node)) break; } let suffixLength = 0; - for (; suffixLength < node.children.length - prefixLength && suffixLength < previousChildren.length - prefixLength; suffixLength += 1) { - if (!nodeDeepEquals(node.children[node.children.length - 1 - suffixLength], previousChildren[previousChildren.length - 1 - suffixLength]?.node)) break; + for ( + ; + suffixLength < node.children.length - prefixLength && + suffixLength < previousChildren.length - prefixLength; + suffixLength += 1 + ) { + if ( + !nodeDeepEquals( + node.children[node.children.length - 1 - suffixLength], + previousChildren[previousChildren.length - 1 - suffixLength]?.node, + ) + ) + break; } for (let i = 0; i < node.children.length; i += 1) { @@ -85,9 +102,15 @@ function recursiveMemoRender( previousIndex = previousChildren.length - (node.children.length - i); } if (previousIndex >= 0) childKey = previousChildren[previousIndex]?.key ?? childKey; - while (childKey && usedKeys.has(childKey) && previousChildren[previousIndex]?.key !== childKey) childKey = `${childKey}+`; + while (childKey && usedKeys.has(childKey) && previousChildren[previousIndex]?.key !== childKey) + childKey = `${childKey}+`; if (childKey) usedKeys.add(childKey); - const childState = recursiveMemoRender(child, previousChildren[previousIndex] ?? null, options, childKey); + const childState = recursiveMemoRender( + child, + previousChildren[previousIndex] ?? null, + options, + childKey, + ); nextChildren.push(childState); if (childState.react !== undefined) children.push(childState.react); } diff --git a/tests/Memoizer.test.tsx b/tests/Memoizer.test.tsx index dfff941c57acf292713740f2422f685af67569cb..ceb1009e3b396cdc5bd3eb0c111e8445675dbb92 100644 --- a/tests/Memoizer.test.tsx +++ b/tests/Memoizer.test.tsx @@ -31,7 +31,9 @@ const documentStateProcessor = unified() ?.slice("suffix: ".length); if (!suffix) return tree; - const target = paragraphs.find((paragraph) => !paragraphText(paragraph).startsWith("suffix: ")); + const target = paragraphs.find( + (paragraph) => !paragraphText(paragraph).startsWith("suffix: "), + ); const firstChild = target?.children[0]; if (firstChild?.type === "text") { const textNode = firstChild as Literal; diff --git a/tests/memoizedHastToReact.test.tsx b/tests/memoizedHastToReact.test.tsx index e9b3a44ab3d6c700249b038a24f8df077cdd436b..f31a6d0293bd8003e028a387a44bc96b4046f0f5 100644 --- a/tests/memoizedHastToReact.test.tsx +++ b/tests/memoizedHastToReact.test.tsx @@ -185,7 +185,9 @@ it("reuses an equal subtree when a sibling of another tag is inserted above it", }); it("preserves an unchanged list item when a sibling item is inserted above it", () => { - const first = renderTree(root(element("ul", [element("li", [text("stable")]), element("li", [text("tail")])]))); + const first = renderTree( + root(element("ul", [element("li", [text("stable")]), element("li", [text("tail")])])), + ); const firstList = childAt(first.react, 0); const firstStable = childAt(firstList, 0); diff --git a/tests/prediction.test.ts b/tests/prediction.test.ts index 77a9f5222cfccaf662db5684fe6e31d81f9198ab..42c925757af33b980e262af8641a19a18d432a89 100644 --- a/tests/prediction.test.ts +++ b/tests/prediction.test.ts @@ -339,12 +339,9 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => { ["incomplete self-closing tag gets hidden", "", ""], - ["completed tag followed by bare angle hides the tail", "<", ""], - [ - "completed tag followed by partial tag hides the tail", - "", - ], + ["completed tag followed by bare angle hides", "<", ""], + ["completed tag followed by partial tag hides", "",