From 3d0d64a698436ea3f3988e535375bf49ee1cf77c Mon Sep 17 00:00:00 2001 From: clem Date: Sat, 28 Mar 2026 00:58:20 +0800 Subject: [PATCH 1/4] docs: add design spec for renderNodes whitespace-only fast path (issue #143) Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-03-28-renderNodes-fast-path-design.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-28-renderNodes-fast-path-design.md diff --git a/docs/superpowers/specs/2026-03-28-renderNodes-fast-path-design.md b/docs/superpowers/specs/2026-03-28-renderNodes-fast-path-design.md new file mode 100644 index 0000000..ab9ab0c --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-renderNodes-fast-path-design.md @@ -0,0 +1,72 @@ +# Design: renderNodes whitespace-only fast path + +**Issue:** #143 — Performance: renderNodes runs full micromark + innerHTML pipeline per {for} iteration +**Date:** 2026-03-28 + +## Problem + +Passages with `{for}` loops over moderate-sized arrays (15-25 items) take 1.5-1.7 seconds to re-render. Chrome CPU profiles show 89% of wall time inside `htmlToPreact` (`src/markup/render.tsx:38`), called once per `{for}` iteration via `renderNodes`. + +Each call runs the full pipeline: build markdown string with placeholders → run micromark (CommonMark + GFM) → `createElement('div')` + `innerHTML` (browser HTML parser) → nobr `

` stripping → recursive DOM walk → Preact vnodes. + +For a passage with a `{for}` loop over 23 items containing HTML elements and `{computed}` expressions, this pipeline runs ~97 times per render. For loop bodies that are 100% HTML + Spindle macros with only whitespace as text, the entire micromark + innerHTML path is pure overhead — micromark wraps content in `

` tags that nobr immediately strips, and innerHTML creates DOM nodes that are immediately discarded after vnode extraction. + +## Solution + +Extend the existing fast path in `renderNodes` to detect whitespace-only text nodes and skip the markdown pipeline when no meaningful markdown content is present. + +### Current fast path (`render.tsx:274-278`) + +```ts +const hasText = nodes.some((n) => n.type === 'text'); +if (!hasText) { + return nodes.map((node, i) => renderSingleNode(node, i)); +} +``` + +This skips the pipeline when there are zero text nodes. But in practice, for-loop bodies almost always contain whitespace text nodes (indentation, newlines between HTML tags), so the fast path never triggers. + +### New fast path + +```ts +const needsMarkdown = nodes.some( + (n) => n.type === 'text' && n.value.trim() !== '', +); +if (!needsMarkdown) { + return nodes.map((node, i) => renderSingleNode(node, i)); +} +``` + +If all text nodes contain only whitespace, skip the pipeline and render each node directly via `renderSingleNode`. Text nodes return their value as-is (whitespace), and non-text nodes (html, macro, variable, expression) render through their existing component paths. + +### Why this is safe + +Whitespace-only text between HTML tags and macros cannot produce meaningful markdown output: + +- **nobr mode:** Micromark wraps whitespace in `

` tags that nobr immediately strips. Skipping produces identical output. +- **non-nobr mode:** Whitespace-only `

` tags are empty and invisible. The rendered output is identical whether we run the pipeline or skip it. +- **Paragraph breaks:** Blank lines in whitespace-only text between non-text nodes (HTML elements, macros) don't create visible paragraph separation — those nodes render as their own components independent of `

` wrapping. + +### What this eliminates + +For a 23-item for-loop with HTML cards (the profiled scenario): + +- ~97 micromark parse calls +- ~97 `createElement('div')` + `innerHTML` DOM constructions +- ~97 nobr `querySelectorAll(':scope > p')` + `replaceWith` passes +- ~97 recursive DOM tree walks to build Preact vnodes +- Associated GC pressure (18-38 MB heap churn from temporary DOM nodes) + +## Files changed + +| File | Change | +| -------------------------- | ------------------------------------------------- | +| `src/markup/render.tsx` | Modify the `needsMarkdown` check in `renderNodes` | +| `test/dom/render.test.tsx` | Add tests for fast path behavior | + +## Test plan + +1. **Fast path triggers for HTML+macros with whitespace text** — for-loop body with HTML elements, macros, variables, and only whitespace text nodes renders correctly +2. **Slow path preserved for real markdown** — for-loop body with markdown syntax (`**bold**`, lists, tables) still processes through micromark +3. **Edge case: mixed nodes** — whitespace text + non-whitespace text in the same node list goes through the pipeline +4. **All existing render tests pass** — ~50 tests covering markdown, variables, links, tables, SVG, nobr, className/id, computed, consecutive mutations From 34c2f35b30c17b2b19416b5f3e55edf6fa14946a Mon Sep 17 00:00:00 2001 From: clem Date: Sat, 28 Mar 2026 01:01:22 +0800 Subject: [PATCH 2/4] docs: add implementation plan for renderNodes fast path (#143) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-28-renderNodes-fast-path.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-renderNodes-fast-path.md diff --git a/docs/superpowers/plans/2026-03-28-renderNodes-fast-path.md b/docs/superpowers/plans/2026-03-28-renderNodes-fast-path.md new file mode 100644 index 0000000..82aa836 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-renderNodes-fast-path.md @@ -0,0 +1,250 @@ +# renderNodes Whitespace-Only Fast Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the micromark + innerHTML pipeline for `{for}` loop iterations whose text nodes are whitespace-only, fixing the 1.5s+ re-render bottleneck in issue #143. + +**Architecture:** Single predicate change in `renderNodes()` — extend the existing `!hasText` fast path to also skip the markdown pipeline when all text nodes contain only whitespace. No new files, no new dependencies. + +**Tech Stack:** Preact, TypeScript, Vitest (happy-dom environment) + +--- + +### Task 1: Write correctness baseline — fast path for HTML + macros with whitespace text + +**Files:** + +- Modify: `test/dom/render.test.tsx` + +This test verifies that a for-loop body containing HTML elements, macros, variables, and only whitespace text nodes renders correctly. The tokenizer produces whitespace text nodes from indentation/newlines between HTML tags — this is the exact pattern from the profiled scenario. + +- [ ] **Step 1: Write the test** + +Add a new `describe` block at the end of the top-level `describe('renderNodes', ...)` in `test/dom/render.test.tsx`: + +```tsx +describe('whitespace-only fast path (issue #143)', () => { + it('renders HTML + macros with only whitespace text nodes', () => { + useStoryStore.getState().setVariable('items', [ + { id: 'a', name: 'Alpha', status: 'active' }, + { id: 'b', name: 'Beta', status: 'locked' }, + { id: 'c', name: 'Gamma', status: 'active' }, + ]); + + // For-loop body is HTML + macros + variables with whitespace between tags. + // The tokenizer produces text nodes for "\n " indentation — these are + // whitespace-only and should not trigger the markdown pipeline. + const markup = [ + '{for @item of $items}', + '

', + ' {@item.name}', + ' {if @item.status === "active"}', + ' Active', + ' {/if}', + '
', + '{/for}', + ].join('\n'); + + const container = document.createElement('div'); + const tokens = tokenize(markup); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + + const cards = container.querySelectorAll('.card'); + expect(cards).toHaveLength(3); + expect(cards[0].querySelector('.name')!.textContent).toBe('Alpha'); + expect(cards[0].querySelector('.badge')!.textContent).toBe('Active'); + expect(cards[1].querySelector('.badge')).toBeNull(); + expect(cards[2].querySelector('.badge')!.textContent).toBe('Active'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes (baseline)** + +This test should already pass against the current code since it tests correctness, not the fast path itself. Run it to confirm the test setup is valid: + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx vitest run test/dom/render.test.tsx -t "renders HTML + macros with only whitespace text nodes"` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add test/dom/render.test.tsx +git commit -m "test: add fast path correctness test for HTML + whitespace-only text (#143)" +``` + +--- + +### Task 2: Write correctness baseline — markdown still processed when text has content + +**Files:** + +- Modify: `test/dom/render.test.tsx` + +This test verifies that real markdown syntax inside a for-loop body still goes through micromark. The predicate change must not break this case. + +- [ ] **Step 1: Write the test** + +Add inside the `describe('whitespace-only fast path (issue #143)', ...)` block: + +```tsx +it('preserves markdown processing when text nodes have content', () => { + useStoryStore + .getState() + .setVariable('items', [{ name: 'Alpha' }, { name: 'Beta' }]); + + // Text node "**bold** " has non-whitespace content → must go through micromark + const markup = [ + '{for @item of $items}', + '**{@item.name}** is ready', + '{/for}', + ].join('\n'); + + const container = document.createElement('div'); + const tokens = tokenize(markup); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + + const strongs = container.querySelectorAll('strong'); + expect(strongs).toHaveLength(2); + expect(strongs[0].textContent).toBe('Alpha'); + expect(strongs[1].textContent).toBe('Beta'); +}); +``` + +- [ ] **Step 2: Run test to verify it passes (baseline)** + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx vitest run test/dom/render.test.tsx -t "preserves markdown processing when text nodes have content"` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add test/dom/render.test.tsx +git commit -m "test: add markdown preservation test for non-whitespace text (#143)" +``` + +--- + +### Task 3: Write correctness baseline — mixed whitespace and non-whitespace text + +**Files:** + +- Modify: `test/dom/render.test.tsx` + +Edge case: a node list where some text nodes are whitespace-only but at least one has real content. The pipeline must still run. + +- [ ] **Step 1: Write the test** + +Add inside the `describe('whitespace-only fast path (issue #143)', ...)` block: + +```tsx +it('uses markdown pipeline when any text node has non-whitespace content', () => { + // Mix of whitespace text nodes (from indentation) and a real text node + const markup = '
\n A **bold** label\n
'; + + const container = document.createElement('div'); + const tokens = tokenize(markup); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + + const box = container.querySelector('.box'); + expect(box).not.toBeNull(); + const strong = box!.querySelector('strong'); + expect(strong).not.toBeNull(); + expect(strong!.textContent).toBe('bold'); +}); +``` + +- [ ] **Step 2: Run test to verify it passes (baseline)** + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx vitest run test/dom/render.test.tsx -t "uses markdown pipeline when any text node has non-whitespace content"` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add test/dom/render.test.tsx +git commit -m "test: add mixed whitespace/content text edge case test (#143)" +``` + +--- + +### Task 4: Implement the fast path predicate change + +**Files:** + +- Modify: `src/markup/render.tsx:274-278` + +- [ ] **Step 1: Modify the fast path check** + +In `src/markup/render.tsx`, replace lines 274-278: + +```tsx +// If there's no text at all, render nodes directly without markdown +const hasText = nodes.some((n) => n.type === 'text'); +if (!hasText) { + return nodes.map((node, i) => renderSingleNode(node, i)); +} +``` + +With: + +```tsx +// Skip the markdown pipeline when text nodes contain only whitespace. +// This eliminates ~97 redundant micromark + innerHTML calls per render +// in {for} loops over HTML + macro content (issue #143). +const needsMarkdown = nodes.some( + (n) => n.type === 'text' && n.value.trim() !== '', +); +if (!needsMarkdown) { + return nodes.map((node, i) => renderSingleNode(node, i)); +} +``` + +- [ ] **Step 2: Run all tests to verify nothing breaks** + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx vitest run` +Expected: All tests PASS — the three new tests from Tasks 1-3 plus all existing render, expression, and DOM tests. + +- [ ] **Step 3: Run type check** + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/markup/render.tsx +git commit -m "fix: skip markdown pipeline for whitespace-only text in renderNodes (#143) + +Extend the existing fast path to detect when all text nodes contain only +whitespace (indentation, newlines between HTML tags). In this case, skip +the micromark + innerHTML + DOM walk pipeline entirely and render nodes +directly via renderSingleNode. + +For a {for} loop over 23 HTML cards, this eliminates ~97 redundant +pipeline calls that account for 89% of wall time in Chrome profiles." +``` + +--- + +### Task 5: Run full test suite and verify + +**Files:** None (verification only) + +- [ ] **Step 1: Run the complete test suite** + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx vitest run` +Expected: All tests PASS + +- [ ] **Step 2: Run type check** + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Verify the fast path test specifically** + +Run: `PATH="/home/clemens/.nvm/versions/node/v22.18.0/bin:$PATH" npx vitest run test/dom/render.test.tsx -t "whitespace-only fast path"` +Expected: All 3 tests in the `whitespace-only fast path` describe block PASS From e6d507eead0fc42675a567d08b55717d2762c7d3 Mon Sep 17 00:00:00 2001 From: clem Date: Sat, 28 Mar 2026 01:03:00 +0800 Subject: [PATCH 3/4] test: add correctness baseline tests for renderNodes fast path (#143) --- test/dom/render.test.tsx | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/test/dom/render.test.tsx b/test/dom/render.test.tsx index 925bb46..9b7a65a 100644 --- a/test/dom/render.test.tsx +++ b/test/dom/render.test.tsx @@ -465,4 +465,100 @@ describe('renderNodes', () => { expect(el.innerHTML).not.toContain('<svg'); }); }); + + // Issue #136: consecutive {set} macros can't see each other's temp mutations + describe('consecutive {set} mutations (issue #136)', () => { + it('second {set} sees temp set by first {set}', () => { + const container = document.createElement('div'); + const tokens = tokenize( + '{set _x = [3, 1, 2]}{set _y = _x.slice().sort()}Result: {_y}', + ); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + expect(container.textContent).toContain('Result: 1,2,3'); + }); + + it('second {set} sees $var set by first {set}', () => { + const container = document.createElement('div'); + const tokens = tokenize('{set $a = 10}{set $b = $a + 5}Answer: {$b}'); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + expect(container.textContent).toContain('Answer: 15'); + }); + }); + + describe('whitespace-only fast path (issue #143)', () => { + it('renders HTML + macros with only whitespace text nodes', () => { + useStoryStore.getState().setVariable('items', [ + { id: 'a', name: 'Alpha', status: 'active' }, + { id: 'b', name: 'Beta', status: 'locked' }, + { id: 'c', name: 'Gamma', status: 'active' }, + ]); + + // For-loop body is HTML + macros + variables with whitespace between tags. + // The tokenizer produces text nodes for "\n " indentation — these are + // whitespace-only and should not trigger the markdown pipeline. + const markup = [ + '{for @item of $items}', + '
', + ' {@item.name}', + ' {if @item.status === "active"}', + ' Active', + ' {/if}', + '
', + '{/for}', + ].join('\n'); + + const container = document.createElement('div'); + const tokens = tokenize(markup); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + + const cards = container.querySelectorAll('.card'); + expect(cards).toHaveLength(3); + expect(cards[0].querySelector('.name')!.textContent).toBe('Alpha'); + expect(cards[0].querySelector('.badge')!.textContent).toBe('Active'); + expect(cards[1].querySelector('.badge')).toBeNull(); + expect(cards[2].querySelector('.badge')!.textContent).toBe('Active'); + }); + + it('preserves markdown processing when text nodes have content', () => { + useStoryStore + .getState() + .setVariable('items', [{ name: 'Alpha' }, { name: 'Beta' }]); + + // Text node "**bold** " has non-whitespace content → must go through micromark + const markup = [ + '{for @item of $items}', + '**{@item.name}** is ready', + '{/for}', + ].join('\n'); + + const container = document.createElement('div'); + const tokens = tokenize(markup); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + + const strongs = container.querySelectorAll('strong'); + expect(strongs).toHaveLength(2); + expect(strongs[0].textContent).toBe('Alpha'); + expect(strongs[1].textContent).toBe('Beta'); + }); + + it('uses markdown pipeline when any text node has non-whitespace content', () => { + // Mix of whitespace text nodes (from indentation) and a real text node + const markup = '
\n A **bold** label\n
'; + + const container = document.createElement('div'); + const tokens = tokenize(markup); + const ast = buildAST(tokens); + render(<>{renderNodes(ast)}, container); + + const box = container.querySelector('.box'); + expect(box).not.toBeNull(); + const strong = box!.querySelector('strong'); + expect(strong).not.toBeNull(); + expect(strong!.textContent).toBe('bold'); + }); + }); }); From f1f87a13930bb79bb29a38b0f7fb5a07d6659a54 Mon Sep 17 00:00:00 2001 From: clem Date: Sat, 28 Mar 2026 01:06:39 +0800 Subject: [PATCH 4/4] fix: skip markdown pipeline for whitespace-only text in renderNodes (#143) Extend the existing fast path to detect when all text nodes contain only whitespace (indentation, newlines between HTML tags). In this case, skip the micromark + innerHTML + DOM walk pipeline entirely and render nodes directly via renderSingleNode. For a {for} loop over 23 HTML cards, this eliminates ~97 redundant pipeline calls that account for 89% of wall time in Chrome profiles. --- src/markup/render.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/markup/render.tsx b/src/markup/render.tsx index 31f725b..6078490 100644 --- a/src/markup/render.tsx +++ b/src/markup/render.tsx @@ -271,9 +271,16 @@ export function renderNodes( ): preact.ComponentChildren { if (nodes.length === 0) return null; - // If there's no text at all, render nodes directly without markdown - const hasText = nodes.some((n) => n.type === 'text'); - if (!hasText) { + // Skip the markdown pipeline when text nodes contain only whitespace. + // This eliminates ~97 redundant micromark + innerHTML calls per render + // in {for} loops over HTML + macro content (issue #143). + // However, don't skip if any text node contains blank lines (\n\n), as those + // have markdown semantics (paragraph separation). + const needsMarkdown = nodes.some( + (n) => + n.type === 'text' && (n.value.trim() !== '' || /\n\s*\n/.test(n.value)), + ); + if (!needsMarkdown) { return nodes.map((node, i) => renderSingleNode(node, i)); }