Description
Typing into a field gets slower as the whole document grows, not just the part you're editing. Ordinary typing already feels sluggish, and holding a key down freezes the editor for a second or two before every character lands at once.
_experimentalVirtualization would help, but it only covers the root area zone (depth === 0). If your content is wrapped in a root layout component (a common pattern), it all lives in nested slots at depth >= 1 and gets nothing.
Environment
- Puck 0.21.3 (latest), React 18, Chrome desktop.
- The repro runs with
iframe={{ enabled: false }}. Enabling the iframe is the same or slightly worse, so the cost isn't in the iframe / style sync.
Steps to reproduce
Stock Puck, two trivial components, no resolveData / hooks.
- Open
?n=300&mode=nested&virt=0
- Select a field on the canvas
- Type in its
label in the sidebar — laggy
- Hold a key down — the editor freezes
What happens
Per-keystroke time-to-paint (nested, virtualization off) climbs with node count:
| nodes |
~135 |
~200 |
~300 |
| latency |
35 ms |
40 ms |
54 ms |
Each keystroke already runs well over one frame (16 ms), so normal typing feels janky before you ever hold a key. Holding a key at N=300 (one input per frame, ~33 ms key-repeat budget): 26 of 27 frames go over budget (worst 67 ms), so the input queue starves and the characters arrive in a burst.
Virtualization fixes this for flat / root-level content (N=300: 54 → 33 ms, 0 frames over budget) but does nothing for nested content (still ~55 ms, still freezes), because of the gate:
// components/DropZone/index.tsx
const isRootAreaZone = (areaId ?? rootAreaId) === rootAreaId && depth === 0;
const shouldVirtualizeItems = _experimentalVirtualization && isRootAreaZone;
What I expect to happen
Per-keystroke cost should depend on the field I'm editing, not the whole document, and holding a key shouldn't freeze the editor. Nested slots should get the same relief root-level content already gets.
Cause
Each keystroke dispatches a replace. The reducer skips off-path subtrees, so it's not a mass re-render of the tree — in practice only the edited leaf re-renders. But every dispatch still costs O(mounted nodes): zustand re-runs the selector of every mounted subscriber, and each node carries ~20 of them across DropZoneChild and DraggableComponent. Virtualization is the only thing that shrinks that set, and it's root-only. (Separately, a permissions subscription runs a full flattenData walk on every change even when no resolvePermissions is defined — a smaller cost on top.)
Suggested fix
useLocalValue (components/AutoField/lib/use-local-value.ts) dispatches replace synchronously on every keystroke, even though the input already renders from local state. Debouncing the store commit while the field is focused (commit on pause / blur / unmount) drops the per-keystroke dispatch entirely, regardless of document size, nesting, or virtualization.
N=300 nested, before → after: per-keystroke 54 → 33 ms; held-key 26/27 → 0/27 frames over budget. The input still renders from local state, so the caret doesn't jump and custom fields are untouched.
This is roughly the debounce the field code had in #198 and lost in the #469 refactor. Trade-off: the canvas, onAction, and resolveData update on the debounce interval instead of per character (undo history already debounces at 250 ms). Could sit behind a prop (0 = current behavior). Happy to open a PR.
Description
Typing into a field gets slower as the whole document grows, not just the part you're editing. Ordinary typing already feels sluggish, and holding a key down freezes the editor for a second or two before every character lands at once.
_experimentalVirtualizationwould help, but it only covers the root area zone (depth === 0). If your content is wrapped in a root layout component (a common pattern), it all lives in nested slots atdepth >= 1and gets nothing.Environment
iframe={{ enabled: false }}. Enabling the iframe is the same or slightly worse, so the cost isn't in the iframe / style sync.Steps to reproduce
Stock Puck, two trivial components, no
resolveData/ hooks.?n=300&mode=nested&virt=0labelin the sidebar — laggyWhat happens
Per-keystroke time-to-paint (nested, virtualization off) climbs with node count:
Each keystroke already runs well over one frame (16 ms), so normal typing feels janky before you ever hold a key. Holding a key at N=300 (one input per frame, ~33 ms key-repeat budget): 26 of 27 frames go over budget (worst 67 ms), so the input queue starves and the characters arrive in a burst.
Virtualization fixes this for flat / root-level content (N=300: 54 → 33 ms, 0 frames over budget) but does nothing for nested content (still ~55 ms, still freezes), because of the gate:
What I expect to happen
Per-keystroke cost should depend on the field I'm editing, not the whole document, and holding a key shouldn't freeze the editor. Nested slots should get the same relief root-level content already gets.
Cause
Each keystroke dispatches a
replace. The reducer skips off-path subtrees, so it's not a mass re-render of the tree — in practice only the edited leaf re-renders. But every dispatch still costs O(mounted nodes): zustand re-runs the selector of every mounted subscriber, and each node carries ~20 of them acrossDropZoneChildandDraggableComponent. Virtualization is the only thing that shrinks that set, and it's root-only. (Separately, a permissions subscription runs a fullflattenDatawalk on every change even when noresolvePermissionsis defined — a smaller cost on top.)Suggested fix
useLocalValue(components/AutoField/lib/use-local-value.ts) dispatchesreplacesynchronously on every keystroke, even though the input already renders from local state. Debouncing the store commit while the field is focused (commit on pause / blur / unmount) drops the per-keystroke dispatch entirely, regardless of document size, nesting, or virtualization.N=300 nested, before → after: per-keystroke 54 → 33 ms; held-key 26/27 → 0/27 frames over budget. The input still renders from local state, so the caret doesn't jump and custom fields are untouched.
This is roughly the debounce the field code had in #198 and lost in the #469 refactor. Trade-off: the canvas,
onAction, andresolveDataupdate on the debounce interval instead of per character (undo history already debounces at 250 ms). Could sit behind a prop (0= current behavior). Happy to open a PR.