perf: retain local text updates beside independent scroll regions

This commit is contained in:
Kinneyzhang 2026-09-06 20:50:01 +08:00
parent dfa097e988
commit 21cf79cdfa
3 changed files with 282 additions and 8 deletions

View File

@ -7903,6 +7903,12 @@ suppresses the ordinary fallback when any fact is missing."
:retain-external-owner-suffix-p t)))
(when (and geometry-proof paint-valid-p disjoint-p
allocation-disjoint-p cascade-safe-p
;; The single-owner span proof can retain unrelated scroll
;; subtrees. Its geometry partition does not prove that this
;; mixed transaction's paint owners leave scroll caches intact.
(null (plist-get old-state :scroll-region-ids))
(null (ebox-incremental--hash-keys
(plist-get prepared :scroll-state-table)))
(not (plist-get prepared :scroll-state-transaction))
(not (plist-get prepared :scroll-patch-fast-p))
root-id
@ -9282,6 +9288,39 @@ the surface renderer and role/overflow validators remain the final proof."
(plist-get slot :start-pixel)))
slots))))))
(defun ebox-incremental--span-owner-disjoint-from-scroll-p
(buffer old-state prepared candidate-state owner-id)
"Return non-nil when OWNER-ID cannot change any retained scroll content.
The active scroll set and each scroll box identity must survive preparation.
An ancestor or descendant scroll box still requires the ordinary renderer to
regenerate its cached lines; only separate branches may keep those lines."
(let* ((regions (plist-get old-state :scroll-region-ids))
(next-table (plist-get prepared :scroll-state-table)))
(and (= (length regions)
(if (hash-table-p next-table) (hash-table-count next-table) 0))
(cl-every
(lambda (region)
(let* ((old-scroll (gethash region ebox--scroll-global-state))
(next-scroll (and next-table (gethash region next-table)))
(old-box (plist-get old-scroll :box))
(next-box (plist-get next-scroll :box))
(scroll-id (plist-get old-box :node-id)))
(and scroll-id next-box
(eq (plist-get old-scroll :buffer) buffer)
(equal scroll-id (plist-get next-box :node-id))
(eq old-box (gethash scroll-id (plist-get old-state :node-table)))
(eq old-box (gethash region (plist-get old-state :region-box-table)))
(eq next-box (gethash scroll-id (plist-get candidate-state :node-table)))
(eq next-box (gethash region (plist-get candidate-state :region-box-table)))
(not (equal scroll-id owner-id))
(not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id))
(not (ebox--runtime-ancestor-id-p buffer owner-id scroll-id))
(let ((ebox-incremental--buffer-render-state-override
(cons buffer candidate-state)))
(and (not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id))
(not (ebox--runtime-ancestor-id-p buffer owner-id scroll-id)))))))
regions))))
(defun ebox-incremental--single-span-patch-projection-proof
(buffer old-state prepared candidate-state owner-plan
&optional path-copied-p)
@ -9411,10 +9450,22 @@ metadata; this predicate only authorizes the local attempt."
local-typography-p)
(ebox-incremental--cascade-local-owner-proof-p
old-state candidate-state (list dirty))))
(null (plist-get old-state :scroll-region-ids))
(or (null (plist-get candidate-state :scroll-region-ids))
(null (ebox-incremental--hash-keys
(plist-get candidate-state :scroll-state-table))))
(ebox-incremental--span-owner-disjoint-from-scroll-p
buffer old-state prepared candidate-state owner-id)
;; Admit the new scroll coexistence case only for an exact
;; content extent. Length or width changes can alter an
;; ancestor's line filler outside the local owner's spans.
(or (null (plist-get old-state :scroll-region-ids))
(let ((before (plist-get (plist-get dirty :old-signature)
:content))
(after (plist-get (plist-get dirty :new-signature)
:content)))
(and (stringp before) (stringp after)
(not (cl-intersection
changed-keys ebox--typography-style-signature-keys))
(= (length before) (length after))
(= (ebox--string-pixel-width before)
(ebox--string-pixel-width after)))))
;; The ancestor allocation proof is tied to the published
;; viewport slot. A declarative commit does not alter that
;; context; final footprint validation still rejects a miss.
@ -9465,6 +9516,10 @@ metadata; this predicate only authorizes the local attempt."
:ancestor-slot-signature ancestor-slot-signature
:variable-content-p
(and snapshot
;; With retained scroll siblings, the final rendered
;; footprint must remain exact even when raw content has
;; equal width under the current buffer's default font.
(null (plist-get old-state :scroll-region-ids))
(memq :content changed-keys)
(stringp
(plist-get (plist-get dirty :new-signature) :content))

View File

@ -5630,10 +5630,17 @@ published runtime and must be consumed without clearing those shared nodes."
context projection state
(ebox-surface--paint-fragments
previous-state state dirty-set old-text)))
(unless (memq projection-kind
'(scroll-patch formatting-context-reflow
mixed-owner-reflow))
(plist-put state :region-box-table (make-hash-table :test 'equal)))
(cond
((memq projection-kind '(span-patch owner-scoped))
;; A local owner render visits only its own regions. Keep the
;; complete candidate registry, including disjoint retained scroll
;; boxes, in a private table until the transaction is accepted.
(plist-put state :region-box-table
(copy-hash-table (plist-get state :region-box-table))))
((not (memq projection-kind
'(scroll-patch formatting-context-reflow
mixed-owner-reflow)))
(plist-put state :region-box-table (make-hash-table :test 'equal))))
(let* ((_initial-native
(when initial-native-p
;; Initial mount has no incremental planner. Surface owns

View File

@ -40,6 +40,218 @@
"Return one stable declarative root containing CONTENT."
(ebox-test-box :key 'root (ebox-test-text content)))
(defun ebox-commit-test--scroll-sibling-root (content &optional nested)
"Return CONTENT beside an active scroll box, or inside it when NESTED."
(let ((label (ebox-test-text content :key 'label :source-identity 'label)))
(ebox-test-column :key 'root :width '(120)
(unless nested label)
(ebox-test-box :key 'scroll :id "scroll" :height 2 :width '(80)
:overflow 'scroll
(ebox-test-column
(when nested label)
(ebox-test-text "line-a\nline-b\nline-c\nline-d"
:key 'scroll-lines :source-identity 'scroll-lines
:color "#123456"))))))
(defun ebox-commit-test--replace-scroll-sibling-label (buffer content)
"Commit CONTENT into BUFFER's stable label host."
(let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-host-ref
candidate 'label
(ebox-test-text content :key 'label :source-identity 'label))
(ebox-commit buffer candidate)))
(ert-deftest ebox-commit-local-content-retains-disjoint-scroll ()
"An unrelated scroll box keeps its caches and registry across a local edit."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-commit-test--scroll-sibling-root "old"))
(let* ((before (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get before :scroll-region-ids)))
(old-scroll (gethash scroll-id ebox--scroll-global-state))
(raw (plist-get old-scroll :content-lines))
(rendered (plist-get old-scroll :rendered-content-lines))
(region-count (hash-table-count (plist-get before :region-box-table)))
(root-render (symbol-function 'ebox-surface--render-candidate))
(root-renders 0)
report)
(should scroll-id)
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (state)
(cl-incf root-renders)
(funcall root-render state))))
(setq report
(ebox-commit-test--replace-scroll-sibling-label
(current-buffer) "new")))
(should (memq (plist-get report :projection-kind) '(span-patch owner-scoped)))
(should (zerop root-renders))
(should-not (plist-get report :tp-full-root))
(let* ((after (ebox--buffer-render-state (current-buffer)))
(scroll (gethash scroll-id ebox--scroll-global-state)))
(should (= region-count (hash-table-count (plist-get after :region-box-table))))
(should (eq (plist-get scroll :box) (gethash scroll-id ebox--region-box-table)))
(should (eq raw (plist-get scroll :content-lines)))
(should (eq rendered (plist-get scroll :rendered-content-lines))))
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (string-match-p "new" (buffer-string)))
(should (string-match-p "line-c" (buffer-string)))
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p "new" (buffer-string)))
(should (string-match-p "line-a" (buffer-string))))))
(ert-deftest ebox-commit-disjoint-scroll-rolls-back-with-local-content ()
"Late rejection restores both the local content and the scroll registry."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-commit-test--scroll-sibling-root "old"))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get state :scroll-region-ids)))
(scroll (gethash scroll-id ebox--scroll-global-state))
(box (gethash scroll-id ebox--region-box-table))
(before (buffer-string)))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject local content publication"))))
(should-error
(ebox-commit-test--replace-scroll-sibling-label (current-buffer) "new")))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (eq scroll (gethash scroll-id ebox--scroll-global-state)))
(should (eq box (gethash scroll-id ebox--region-box-table)))
(should (equal-including-properties before (buffer-string)))
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (string-match-p "old" (buffer-string)))
(ebox-commit-test--replace-scroll-sibling-label (current-buffer) "new")
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p "new" (buffer-string))))))
(ert-deftest ebox-commit-local-content-inside-scroll-keeps-full-cache-update ()
"A scrolling ancestor still needs its retained content regenerated."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-commit-test--scroll-sibling-root "old" t))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get state :scroll-region-ids)))
(report (ebox-commit-test--replace-scroll-sibling-label
(current-buffer) "new")))
(should-not (memq (plist-get report :projection-kind) '(span-patch owner-scoped)))
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p "new" (buffer-string)))
(should-not (string-match-p "old" (buffer-string))))))
(ert-deftest ebox-commit-disjoint-scroll-proof-rejects-incomplete-or-overlapping-state ()
"Missing mappings, changed membership and either ancestor direction reject."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-commit-test--scroll-sibling-root "old"))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(owner (plist-get (ebox--host-ref-node (current-buffer) 'label) :node-id))
(root (plist-get (plist-get state :root-node) :node-id))
(region (car (plist-get state :scroll-region-ids)))
(scroll (gethash region ebox--scroll-global-state))
(scroll-id (plist-get (plist-get scroll :box) :node-id))
(table (make-hash-table :test 'equal))
(prepared (list :scroll-state-table table))
(candidate (copy-sequence state)))
(puthash region scroll table)
(should (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate root))
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate scroll-id))
(remhash region table)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))
(puthash 'different-region scroll table)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))
(clrhash table)
(puthash region scroll table)
(let ((regions (copy-hash-table (plist-get state :region-box-table))))
(remhash region regions)
(plist-put candidate :region-box-table regions)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner)))
(plist-put candidate :region-box-table (plist-get state :region-box-table))
(let ((parents (copy-hash-table (plist-get state :parent-table))))
(puthash scroll-id owner parents)
(plist-put candidate :parent-table parents)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))))))
(ert-deftest ebox-commit-content-shift-preserves-later-scroll-targets ()
"Longer and shorter local text retain the later scroll widget's targets."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-commit-test--scroll-sibling-root "old"))
(let ((scroll-id (car (plist-get (ebox--buffer-render-state (current-buffer))
:scroll-region-ids))))
(dolist (value '("longer" "x"))
(ebox-commit-test--replace-scroll-sibling-label (current-buffer) value)
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (string-match-p (concat "\\`" value) (buffer-string)))
(should (string-match-p "line-c" (buffer-string)))
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p (concat "\\`" value) (buffer-string)))
(should (string-match-p "line-a" (buffer-string)))))))
(ert-deftest ebox-commit-mixed-content-and-scroll-paint-refreshes-cache ()
"An unrelated label edit cannot authorize stale paint in a scroll cache."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-commit-test--scroll-sibling-root "old"))
(let* ((scroll-id (car (plist-get (ebox--buffer-render-state (current-buffer))
:scroll-region-ids)))
(candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace-host-ref
candidate 'label (ebox-test-text "new" :key 'label :source-identity 'label))
(ebox-candidate-replace-host-ref
candidate 'scroll-lines
(ebox-test-text "line-a\nline-b\nline-c\nline-d"
:key 'scroll-lines :source-identity 'scroll-lines
:color "#654321"))
(let ((report (ebox-commit (current-buffer) candidate)))
(should-not (eq (plist-get report :projection-kind) 'mixed-owner-reflow)))
(dotimes (_ 2)
(goto-char (point-min))
(search-forward "line-a")
(should (equal "#654321"
(ebox-commit-test--face-value
(get-text-property (1- (point)) 'face) :foreground)))
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (= -1 (ebox--scroll-region-by scroll-id -1)))))))
(ert-deftest ebox-commit-disjoint-scroll-validates-rendered-font-extent ()
"Equal raw widths do not authorize changed rendered glyph widths."
(let ((measure (symbol-function 'ebox--string-pixel-width)))
;; Model an unchanged proportional font: raw WWW/iii both measure three
;; units, but styled iii is narrower. The final painted line must still
;; include exactly the filler required by its 120px parent allocation.
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (string)
(let ((width (funcall measure string)) (start 0))
(while (string-match "iii" string start)
(when (get-text-property (match-beginning 0) 'face string)
(cl-decf width))
(setq start (match-end 0)))
width))))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-test-column :key 'root :width '(120)
(ebox-test-text "WWW" :font-size 16 :key 'label :source-identity 'label)
(ebox-test-box :height 1 :overflow 'scroll
(ebox-test-text "first\nsecond"))))
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace-host-ref
candidate 'label
(ebox-test-text "iii" :font-size 16 :key 'label :source-identity 'label))
(ebox-commit (current-buffer) candidate))
(goto-char (point-min))
(should (looking-at "iii"))
(should (= 120 (ebox--string-pixel-width
(buffer-substring (point) (line-end-position)))))))))
(defun ebox-commit-test--hash-facts (table &optional values)
"Return sorted TABLE keys, or key/value pairs when VALUES is non-nil."
(let (facts)