Skip to content

perf(plugin): lazy frontMatter and TOC serialization in QuickJS fast path#1200

Merged
zeroedin merged 5 commits into
mainfrom
feature/lazy-fm-toc
Jul 25, 2026
Merged

perf(plugin): lazy frontMatter and TOC serialization in QuickJS fast path#1200
zeroedin merged 5 commits into
mainfrom
feature/lazy-fm-toc

Conversation

@zeroedin

@zeroedin zeroedin commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replace eager jsonCodec.Marshal + ParseJSON of frontMatter and TOC on every hook call with lazy Object.defineProperty getters backed by Go callbacks (__resolveFM, __resolveTOC)
  • JSON serialization only runs when the plugin actually reads page.frontMatter or page.toc — most hooks never access these, saving ~3.2s on the RHDS benchmark
  • Getter self-replaces with a cached data property on first access; setter allows page.frontMatter = {...} without TypeError
  • Pre-compiled __installLazyFM/__installLazyTOC helper functions called via ctx.Invoke (not Eval) on the hot path

Test results

All 324 plugin spec tests pass with -race, including 18 new lazy serialization tests from issue #1187:

  • frontMatter: lazy accessor install, write-before-read, never-read, read-twice caching, consecutive calls, nil/empty coercion, all 3 payload types
  • TOC: lazy accessor install, write-before-read, never-read, read-twice caching, consecutive calls, nil/empty omission
SUCCESS! -- 324 Passed | 0 Failed | 0 Pending | 0 Skipped

Full suite: go test -race ./... — all packages pass.

Closes #1187

Summary by CodeRabbit

  • Performance
    • Improved plugin hook performance by loading front matter and table-of-contents data only when accessed.
    • Cached these values after first access to avoid repeated processing.
  • Bug Fixes
    • Preserved expected behavior when hook scripts modify front matter.
    • Correctly handles empty front matter and omits empty table-of-contents data.
  • Tests
    • Added coverage for lazy loading, caching, repeated hook calls, and empty values.

zeroedin and others added 4 commits July 24, 2026 21:51
…#1187)

14 spec tests under "Lazy frontMatter and TOC serialization (issue #1187)":

frontMatter (9 tests):
- Lazy accessor detection + self-caching on HookRenderedPayload (RED)
- Write-before-read (setter replaces accessor without TypeError)
- Never-read (lazy getter inert when unused)
- Reading twice returns cached value (self-caching)
- Consecutive calls resolve correct frontMatter per call
- Nil frontMatter → empty object (not null, not undefined)
- Empty frontMatter → empty object
- Lazy accessor on HookFormatRenderedPayload (RED)
- Lazy accessor on HookTransformPayload (RED)

TOC (5 tests):
- Lazy accessor detection + self-caching on HookTransformPayload (RED)
- Write-before-read (setter replaces accessor without TypeError)
- Never-read (lazy getter inert when unused)
- Nil TOC → property absent (no lazy getter installed)
- Empty TOC → property absent (no lazy getter installed)

4 tests fail RED (accessor property detection), 10 pass as regression guards.

Refs #1187

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hoist setupQuickJSWithHook to parent Describe scope (P2: was
  duplicated at lines 1635 and 2252, now shared by 31 call sites)
- Add TOC "reading twice returns cached array reference" test
- Add TOC "consecutive hook calls resolve correct TOC per call" test
- Update Phase 5A test count from 93 to 109 (+16 from this PR)
- Update Phase 5 total and running total accordingly
- Update IMPLEMENTATION.md test coverage description to 16 tests

Refs #1187

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
test: spec tests for lazy frontMatter and TOC serialization (#1187)
…path (#1187)

Replace eager JSON marshal of frontMatter and TOC on every hook call with
lazy getters backed by Go callbacks. The JSON work only runs when the plugin
actually reads page.frontMatter or page.toc. Getter self-replaces with a
cached data property on first access; setter supports page.frontMatter = {...}
without TypeError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for alloyssg ready!

Name Link
🔨 Latest commit ea09278
🔍 Latest deploy log https://app.netlify.com/projects/alloyssg/deploys/6a641fb3d478730008f0ae3b
😎 Deploy Preview https://deploy-preview-1200--alloyssg.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6517a81a-7589-485e-879d-264745e4516d

📥 Commits

Reviewing files that changed from the base of the PR and between 4a8733d and ea09278.

📒 Files selected for processing (1)
  • internal/plugin/wasm.go
📝 Walkthrough

Walkthrough

QuickJS hook payloads now lazily serialize frontMatter and toc through Go-backed JavaScript accessors. The change adds pending runtime state, conditional TOC installation, setter and caching behavior, comprehensive tests, and updated implementation plans.

Changes

Lazy payload serialization

Layer / File(s) Summary
Runtime lazy accessor infrastructure
internal/plugin/wasm.go
QuickJSRuntime stores pending values, registers resolver callbacks, and pre-compiles JavaScript functions for lazy frontMatter and toc accessors.
Hook payload integration and validation
internal/plugin/wasm.go, internal/plugin/wasm_test.go
Hook payloads install lazy properties, omit empty TOC values, and validate caching, setters, nil handling, and per-hook-call resolution across payload types.
Implementation plan updates
plans/IMPLEMENTATION.md, plans/PLAN.md
The plans document lazy serialization behavior and update Phase 5 test-count totals.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • zeroedin/alloy#1184: Modifies QuickJS fast-path payload construction for the same frontMatter and toc fields.
  • zeroedin/alloy#1199: Overlaps in the QuickJS lazy getter and resolver implementation.

Sequence Diagram(s)

sequenceDiagram
  participant HookPayload
  participant QuickJSRuntime
  participant LazyAccessor
  participant Plugin
  HookPayload->>QuickJSRuntime: store pending frontMatter or TOC
  HookPayload->>LazyAccessor: install lazy property
  Plugin->>LazyAccessor: read property
  LazyAccessor->>QuickJSRuntime: invoke resolver callback
  QuickJSRuntime-->>LazyAccessor: return serialized JSON
  LazyAccessor-->>Plugin: cache and return value
Loading

Poem

A rabbit hops through payload rows,
Where hidden JSON softly dozes.
Read the field—the getter springs,
Caches data on tiny wings.
Empty TOC stays out of sight,
QuickJS burrows fast tonight. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: lazy frontMatter and TOC serialization in the QuickJS fast path.
Linked Issues check ✅ Passed Implements #1187 requirements: lazy getters, caching, write-before-read, nil/empty handling, and correct behavior across hook paths.
Out of Scope Changes check ✅ Passed Changes are limited to the lazy serialization feature, its tests, and related docs; no unrelated code paths appear added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/lazy-fm-toc

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/plugin/wasm.go (1)

531-562: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pending state is never cleared, so a retained payload resolves another page's frontMatter/TOC.

Resolution is deferred to property-read time, but pendingFM/pendingTOC keep the previous call's values after invokeHookFastPath returns. A plugin that stashes the payload (module-scope var, closure, deferred callback) and reads page.frontMatter during a later hook call gets the current page's data instead of its own — silent cross-page contamination. IMPLEMENTATION.md step 6 specifies clearing this state; that step is missing here.

🛠️ Proposed fix
 	if len(v.TOC) > 0 {
 		r.pendingTOC = v.TOC
 		installFn := r.ctx.Global().GetPropertyStr("__installLazyTOC")
 		defer installFn.Free()
 		if _, err := r.ctx.Invoke(installFn, r.ctx.Global(), obj); err != nil {
 			return nil, fmt.Errorf("hook %q: installing lazy toc getter: %w", name, err)
 		}
 	}
+	defer func() {
+		r.pendingFM = nil
+		r.pendingTOC = nil
+	}()
 	return r.invokeHookFastPath(name, obj)
 }

Clearing turns the stale-read into an empty object / empty array rather than another page's data. If a hard guarantee is wanted, capture a per-call generation counter in the installer and have the resolvers reject mismatched generations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/plugin/wasm.go` around lines 531 - 562, Update
callHookTransformPayload to clear pendingFM and pendingTOC after
invokeHookFastPath completes, including when the hook returns an error, so
retained payloads cannot resolve another page’s data. Preserve the existing
setup and return behavior while ensuring cleanup runs for every call.
🧹 Nitpick comments (1)
internal/plugin/wasm.go (1)

32-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider typing pendingTOC as []content.TOCEntry.

interface{} loses type safety and lets a typed-nil slice defeat the toc == nil check in __resolveTOC. The plan (IMPLEMENTATION.md step 1) specifies the concrete slice type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/plugin/wasm.go` around lines 32 - 33, Change pendingTOC to
[]content.TOCEntry and update the related __resolveTOC handling to use that
concrete type, preserving nil checks so a nil TOC is recognized correctly.
Follow the concrete slice type specified in IMPLEMENTATION.md step 1.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/plugin/wasm.go`:
- Around line 145-167: Update the Marshal error branches in the __resolveFM and
__resolveTOC callbacks to return the encountered error instead of an empty JSON
fallback, allowing it to surface as a JavaScript exception and propagate through
CallHook while preserving the existing nil-data defaults.
- Around line 171-198: Update both __installLazyFM and __installLazyTOC so their
accessor descriptors explicitly set enumerable to true, and add enumerable: true
to each self-replacing data-property descriptor in the getter and setter paths.
Preserve the existing lazy resolution and replacement behavior while ensuring
frontMatter and toc remain visible to Object.keys, spread, and JSON.stringify.

In `@plans/IMPLEMENTATION.md`:
- Around line 730-735: Align the implementation and plan for pending TOC state:
update the QuickJSRuntime.pendingTOC declaration to use []content.TOCEntry and
clear pendingFM and pendingTOC immediately after invokeHookFastPath returns, or
revise the plan to match the existing behavior. Keep the pending state scoped to
each hook invocation and avoid retaining stale references.

---

Outside diff comments:
In `@internal/plugin/wasm.go`:
- Around line 531-562: Update callHookTransformPayload to clear pendingFM and
pendingTOC after invokeHookFastPath completes, including when the hook returns
an error, so retained payloads cannot resolve another page’s data. Preserve the
existing setup and return behavior while ensuring cleanup runs for every call.

---

Nitpick comments:
In `@internal/plugin/wasm.go`:
- Around line 32-33: Change pendingTOC to []content.TOCEntry and update the
related __resolveTOC handling to use that concrete type, preserving nil checks
so a nil TOC is recognized correctly. Follow the concrete slice type specified
in IMPLEMENTATION.md step 1.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 706437af-d20b-46d4-ae21-64c44b97463f

📥 Commits

Reviewing files that changed from the base of the PR and between 1cf6315 and 4a8733d.

📒 Files selected for processing (4)
  • internal/plugin/wasm.go
  • internal/plugin/wasm_test.go
  • plans/IMPLEMENTATION.md
  • plans/PLAN.md

Comment thread internal/plugin/wasm.go
Comment thread internal/plugin/wasm.go
Comment thread plans/IMPLEMENTATION.md

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Scope: internal/plugin/wasm.go (implementation), internal/plugin/wasm_test.go (test helper hoist + 16 new spec tests), plans/PLAN.md, plans/IMPLEMENTATION.md
Intent: Replace eager JSON serialization of frontMatter and TOC on every hook call with lazy Object.defineProperty getters backed by Go callbacks, deferring the JSON work to when plugins actually read the properties.

P1 — High

# File Issue Reviewer Confidence Route
1 wasm.go:511,525,547 Missing pendingFM/pendingTOC cleanup after invokeHookFastPath — IMPLEMENTATION.md line 735 step 6 explicitly requires: "After invokeHookFastPath returns, set r.pendingFM = nil and r.pendingTOC = nil to avoid stale references." None of the three fast-path callers clear these fields. Not a data-corruption bug (each call overwrites before JS runs, mutex is held), but a spec deviation that retains Go heap objects after the last hook call and leaves __resolveFM()/__resolveTOC() (global JS functions) returning stale data if called outside a getter context. Fix: add r.pendingFM = nil and r.pendingTOC = nil to the existing defer block in invokeHookFastPath at line 570. correctness, simplicity, maintainability, standards 95 Fix before merge

P2 — Moderate

# File Issue Reviewer Confidence Route
2 wasm.go:151-153,163-165 Silent error swallowing in __resolveFM/__resolveTOC callbacks — When jsonCodec.Marshal fails, these return empty JSON ("{}" / "[]") with nil error. Pre-PR, setPayloadFrontMatter propagated the error: return fmt.Errorf("marshaling frontMatter: %w", err). The lazy path silently degrades. Practically unlikely (YAML-parsed maps always marshal), but makes marshal failures invisible. Add log.Printf("warning: lazy frontMatter marshal failed: %v", err) before the fallback return on both error branches. correctness, simplicity, maintainability 70 Fix before merge
3 wasm.go:33 pendingTOC typed as interface{} instead of spec's []content.TOCEntry — IMPLEMENTATION.md line 730 specifies the concrete type. Using interface{} loses compile-time type safety and is inconsistent with pendingFM (which uses its concrete type). Importing the content package adds one import line. standards, maintainability 75 Fix before merge

P3 — Advisory

# File Issue Reviewer Confidence Route
4 wasm.go:541,555 Global property lookup per hook call for installer functionsGetPropertyStr("__installLazyFM") crosses the WASM boundary on every fast-path hook call (~2460 times on 820 pages × 3 hooks). The function references are constant after Init(). Caching them as struct fields would eliminate these lookups, but adds lifecycle management in Close(). Estimated savings: 2-12ms per site build — negligible vs the 3.2s saved by the lazy optimization itself. Author's call. performance 75 Optional
5 wasm.go:543,557 ctx.Invoke return value discarded without Free()normalizeJsValue (qjs eval.go:72) returns the raw *Value on success. The discarded values are undefined (no explicit JS return), which are immediate values in QuickJS — Free() is a no-op on them. Not a real leak, but explicit Free() would match the codebase convention. correctness 50 Optional

Coverage

  • Testing gaps: No test verifies that pendingFM/pendingTOC are nilled after hook calls (the "consecutive calls" tests pass because the fields are overwritten, not because they were cleared). No test covers the jsonCodec.Marshal failure path. Both would be architect issues if needed.
  • Residual risks: __resolveFM() and __resolveTOC() are global JS functions — a plugin could call them directly outside a hook getter. With cleanup (finding #1 fixed), they'd return safe fallback values. Without cleanup, they'd return stale data from the previous call.
  • Benchmark gap: Existing benchmarks test the full hook path but don't isolate lazy-vs-eager getter cost. A benchmark comparing a plugin that reads frontMatter vs one that doesn't would quantify the savings and catch regressions. Open an issue if desired.

Verdict: Ready with fixes

Reasoning: The lazy getter pattern is clean, well-tested (16 spec tests), and delivers a major performance win (3.2s → 36ms for frontMatter serialization). The three required fixes are small: (1) add 2 lines of nil-out in the existing defer block to match the spec, (2) add log.Printf on 2 error branches to restore observability, (3) change pendingTOC to []content.TOCEntry to match the spec and gain type safety. All three are under 10 lines of change total. The advisory items are genuine but non-blocking.

- Clear pendingFM/pendingTOC after invokeHookFastPath returns (P1 #1,
  IMPLEMENTATION.md step 6)
- Add log.Printf warning on marshal error paths in __resolveFM/__resolveTOC
  callbacks (P2 #2)
- Type pendingTOC as []content.TOCEntry instead of interface{} (P2 #3)
- Add enumerable: true to all lazy getter descriptors so frontMatter/toc
  remain visible to Object.keys, spread, and JSON.stringify

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zeroedin

Copy link
Copy Markdown
Owner Author

Review response

All fixes pushed in ea09278.

P1 #1 — Missing pendingFM/pendingTOC cleanup

Fixed. Added r.pendingFM = nil and r.pendingTOC = nil to the existing defer block in invokeHookFastPath (line 572–573). This matches IMPLEMENTATION.md step 6 and ensures stale references aren't retained after the last hook call.

P2 #2 — Silent error swallowing in resolve callbacks

Fixed. Added log.Printf("warning: lazy frontMatter marshal failed: %v", err) and log.Printf("warning: lazy TOC marshal failed: %v", err) before the fallback returns. Kept graceful degradation (return empty JSON) rather than surfacing as a JS exception — a marshal failure that can't practically happen shouldn't crash the hook, but should be visible in logs.

P2 #3 — pendingTOC typed as interface{}

Fixed. Changed to []content.TOCEntry, added content import. Matches IMPLEMENTATION.md step 1 and gains compile-time type safety.

P3 #4 — Global property lookup per hook call

Skipped. Estimated savings 2-12ms vs 3.2s saved — not worth the lifecycle management overhead. Can revisit if profiling shows it matters.

P3 #5 — ctx.Invoke return value not Free'd

Skipped. Return values are undefined (immediate values in QuickJS) — Free() is a no-op. Adding it would be cosmetic.

CodeRabbit: Non-enumerable accessors

Fixed. Added enumerable: true to all 6 descriptor sites (accessor get/set on install, data property replacement in getter path, data property replacement in setter path, for both FM and TOC). Preserves parity with pre-lazy SetPropertyStr behavior.

Testing gaps (advisory)

  • No test for pendingFM/pendingTOC cleanup — consecutive-call tests pass because fields are overwritten, not because they were cleared. Would need an architect issue for a spec test that verifies niling.
  • No test for marshal failure path — practically unreachable (YAML-parsed maps always marshal), but the log.Printf makes failures visible.
  • Benchmark gap (lazy-vs-eager isolation) — not in scope for this PR but would be useful for regression detection.

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: All findings addressed ✅

Commit reviewed: ea09278 fix: address review feedback on PR #1200

Previous findings — status

# Finding Status
P1 #1 Missing pendingFM/pendingTOC cleanup ✅ Fixed — r.pendingFM = nil and r.pendingTOC = nil added to invokeHookFastPath defer block (line 578-579). Matches IMPLEMENTATION.md step 6.
P2 #2 Silent error swallowing in resolve callbacks ✅ Fixed — log.Printf added on both marshal error branches (lines 153, 166).
P2 #3 pendingTOC typed as interface{} ✅ Fixed — changed to []content.TOCEntry (line 34), content import added (line 16). Matches IMPLEMENTATION.md spec.

Additional change: enumerable: true on property descriptors

The fix commit also adds enumerable: true to all Object.defineProperty calls — both the initial accessor and the self-replacing data property. This is correct: the old SetPropertyStr("frontMatter", ...) created enumerable properties by default, so JSON.stringify(page) and Object.keys(page) included them. Without enumerable: true, Object.defineProperty defaults to enumerable: false, which would have been a subtle behavioral regression. Good catch.

Verification

  • No test file changes — all 16 spec tests still apply as written
  • No plan file changes
  • content import is the only new import, required for the typed pendingTOC field
  • No encoding/json usage

Verdict: Ready

Reasoning: All three required fixes are correctly applied. The enumerable: true addition is a proactive correctness improvement that prevents a subtle behavioral regression. The implementation now matches the spec, maintains observability on error paths, and properly cleans up Go heap references after hook calls.

@zeroedin
zeroedin merged commit bea39c4 into main Jul 25, 2026
6 checks passed
zeroedin added a commit that referenced this pull request Jul 25, 2026
Merge origin/main into feature/quickjs-outbound-fastpath. Resolves
conflicts between this PR's targeted extraction/BatchCallHook/map fast
path and main's lazy FM/TOC (PR #1200) and Node IPC framing (PR #1192).

Resolution strategy:
- wasm.go: take main's lazy callback approach (return JSON strings,
  parse in JS getter) + add __callHookByName pre-compiled function,
  targeted extractors, callHookLocked/BatchCallHook, callHookMapPayload
- wasm_test.go: keep this branch's spec tests (identity return asserts
  pipeline-consumed fields only, not all fields)
- plans/: take main's version (architect domain)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

perf: lazy frontMatter and TOC serialization in QuickJS payload fast path

1 participant