Problem
LocalsContext bundles both read and write concerns into a single context:
interface LocalsScope {
values: Record<string, unknown>;
update: (key: string, value: unknown) => void;
}
When a for-loop iteration updates @i or @item, the entire values object reference changes, causing all consumers of LocalsContext to re-render — even write-only consumers (Set, Button) that only call scope.update.
Solution
Split into two contexts:
export const LocalsValuesContext = createContext<Record<string, unknown>>({});
export const LocalsUpdateContext = createContext<(key: string, value: unknown) => void>(() => {});
The updateFn reference is stable (via useCallback/useRef), so LocalsUpdateContext never triggers re-renders. Write-only macros subscribe only to LocalsUpdateContext.
Impact
- Reduced re-renders in locals-heavy passages (for-loops, widgets)
- ~8 files affected
Dependency
Builds on #20 (defer state reads in mutating macros). Without that change, mutating macros still read scope.values via useMergedLocals(), negating the split's benefit.
Scope
Matters most in passages with nested for-loops or widgets that modify @var locals. In simple passages without locals, the default empty scope never changes, so the split provides no benefit.
Details
Full plan: .claude/plans/04-split-locals-context.md
Problem
LocalsContextbundles both read and write concerns into a single context:When a for-loop iteration updates
@ior@item, the entirevaluesobject reference changes, causing all consumers ofLocalsContextto re-render — even write-only consumers (Set, Button) that only callscope.update.Solution
Split into two contexts:
The
updateFnreference is stable (viauseCallback/useRef), soLocalsUpdateContextnever triggers re-renders. Write-only macros subscribe only toLocalsUpdateContext.Impact
Dependency
Builds on #20 (defer state reads in mutating macros). Without that change, mutating macros still read
scope.valuesviauseMergedLocals(), negating the split's benefit.Scope
Matters most in passages with nested for-loops or widgets that modify
@varlocals. In simple passages without locals, the default empty scope never changes, so the split provides no benefit.Details
Full plan:
.claude/plans/04-split-locals-context.md