fix: preserve mixed updates after retained node replacement

This commit is contained in:
Kinneyzhang 2026-09-07 03:26:55 +08:00
parent b624f46499
commit 789e0b2f17
2 changed files with 239 additions and 11 deletions

View File

@ -3022,9 +3022,44 @@ valid because their declarative layout and viewport did not change."
(puthash region-id state table)))
snapshot))
(defun ebox-surface--refresh-rendered-layout-snapshots (state)
"Refresh stale base snapshots after rendering STATE's complete root.
Keep unchanged entries and their details. Old published geometry remains
available during planning; replacements here belong only to the candidate."
(let ((snapshots (plist-get state :layout-snapshots))
(nodes (plist-get state :node-table))
(ebox--node-region-ids-cache (make-hash-table :test 'eq))
updated)
(when (hash-table-p snapshots)
(maphash
(lambda (node-id snapshot)
(let* ((node (gethash node-id nodes))
(fresh (and node (ebox--node-layout-snapshot nil node nil))))
(unless (and fresh
(cl-every
(lambda (key)
(equal (plist-get snapshot key) (plist-get fresh key)))
'(:node-id :type :display :region-ids
:style-signature :child-ids)))
(unless updated (setq updated (copy-hash-table snapshots)))
(if fresh
(puthash node-id fresh updated)
(remhash node-id updated)))))
snapshots)
(when updated
(plist-put state :layout-snapshots updated))))
state)
(defun ebox-surface--render-candidate (state)
"Render candidate STATE or optional NODE in isolated side tables."
(ebox-surface--render-candidate-node state (plist-get state :root-node)))
"Render candidate STATE's complete root in isolated side tables."
(let ((rendered
(ebox-surface--render-candidate-node state (plist-get state :root-node))))
;; A retained node id can now describe a different display or subtree.
;; Refresh the complete lightweight snapshot, including ancestor region
;; membership; stripping only span details would preserve stale style facts.
(setq state (ebox-surface--finish-runtime-state state))
(ebox-surface--refresh-rendered-layout-snapshots state)
rendered))
(defun ebox-surface--render-candidate-node (state node)
"Render NODE from candidate STATE in isolated Ebox side tables."
@ -3594,17 +3629,15 @@ Proof mismatches return nil; unexpected rendering errors propagate."
node)))
(retain-properties-p
(and (memq (plist-get state :projection-kind)
'(span-patch owner-scoped))
'(span-patch owner-scoped mixed-owner-reflow))
(ebox-surface--owner-needs-ancestor-paint-p state owner-id))))
(when (and old-snapshot spans node render-node
;; Mixed projection has a separate paint recomposition step.
;; Ordinary spans have only the detached owner's rendered
;; faces; retain the full renderer when enclosing paint would
;; otherwise disappear from changed text.
;; Detached geometry omits enclosing effects. Mixed paint
;; only recomposes dirty paint owners, so unchanged ancestors
;; must also survive its geometry replacement.
(or (not retain-properties-p)
(and previous-state
(not (or role-owned-lines-p allocation-closure-p
retain-external-owner-suffix-p))
(not (or role-owned-lines-p allocation-closure-p))
(ebox-surface--unchanged-enclosing-effects-p
previous-state state owner-id))))
(let* ((rendered
@ -3642,9 +3675,14 @@ Proof mismatches return nil; unexpected rendering errors propagate."
retain-external-owner-suffix-p)
(and owned-rendered
(when-let* ((owners
(ebox-surface--retained-external-owner-suffix
(ebox-surface--retained-external-owner-suffix
buffer spans region-ids)))
(ebox--add-content-owners owned-rendered owners)))
;; The retained-property proof compares detached
;; renders, then restores the complete published
;; owner stack together with every other property.
(if retain-properties-p
owned-rendered
(ebox--add-content-owners owned-rendered owners))))
owned-rendered))
(replacement
(and owned-rendered

View File

@ -816,6 +816,196 @@
(buffer-string)))))))))
(when (buffer-live-p buffer) (kill-buffer buffer))))))
(defun ebox-child-range-test--snapshot-detail (expanded &optional title)
"Return a retained detail root, with TITLE and status when EXPANDED."
(if expanded
(ebox-test-column :key 'detail :source-identity 'detail :overflow 'scroll
:bgcolor "#E8DED1"
(ebox-test-text (propertize (or title "title-1") 'face '(:weight bold))
:key 'title :source-identity 'title)
(ebox-test-text "status" :key 'status :source-identity 'status))
(ebox-test-text "placeholder" :key 'detail :source-identity 'detail)))
(defun ebox-child-range-test--snapshot-replacement-root ()
"Return a detail Range beside painted rows and an active scroll sibling."
(ebox-test-column :width '(200) :bgcolor "#FFFDF8"
(ebox-test-box :key 'row-1 :source-identity 'row-1 :color "#123456"
(ebox-test-text "row-1"))
(ebox-test-box :key 'row-2 :source-identity 'row-2 :color "#654321"
(ebox-test-text "row-2"))
(ebox-test-column :bgcolor "#DDE7EF"
:surface-properties '(help-echo "retained detail help")
(ebox-test-child-range
'details (ebox-child-range-test--snapshot-detail nil)))
(ebox-test-box :key 'scroll :source-identity 'scroll
:height 2 :width '(80) :overflow 'scroll
(ebox-test-text "line-a\nline-b\nline-c\nline-d"))))
(defun ebox-child-range-test--replace-snapshot-detail (buffer expanded)
"Return a BUFFER candidate replacing its detail Range with EXPANDED content."
(let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref
candidate 'details (ebox-child-range-test--snapshot-detail expanded))
candidate))
(defun ebox-child-range-test--assert-current-snapshot (buffer node-id)
"Assert BUFFER's NODE-ID snapshot reflects its current structural/style facts."
(let* ((node (ebox--buffer-runtime-node buffer node-id))
(expected (ebox--node-layout-snapshot buffer node))
(actual (ebox--layout-snapshot buffer node-id)))
(dolist (key '(:type :display :region-ids :style-signature :child-ids))
(ert-info ((format "current snapshot field %S" key))
(should (equal (plist-get expected key) (plist-get actual key)))))))
(defun ebox-child-range-test--assert-full-render-equivalent (buffer)
"Assert BUFFER exactly matches a full render of its published root."
(with-current-buffer buffer
(let* ((state (ebox--buffer-render-state buffer))
(expected (ebox--render-node (plist-get state :root-node)
(plist-get state :source-index))))
(should (equal-including-properties expected (buffer-string))))))
(ert-deftest ebox-child-range-retained-kind-replacement-refreshes-snapshots ()
"Text/Column roundtrips refresh facts only after successful publication."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-child-range-test--snapshot-replacement-root))
(let* ((owner-id (plist-get (ebox--host-ref-node (current-buffer) 'detail)
:node-id))
(sibling-id (plist-get (ebox--host-ref-node (current-buffer) 'row-1)
:node-id))
(ancestors
(let ((node-id owner-id)
(parents (plist-get (ebox--buffer-render-state (current-buffer))
:parent-table))
result)
(while (setq node-id (gethash node-id parents))
(push node-id result))
result)))
(dolist (expanded '(t nil t))
(ert-info ((format "detail expanded=%S" expanded))
(dolist (ancestor ancestors)
(ebox--layout-snapshot (current-buffer) ancestor))
(ebox--ensure-layout-snapshot-details (current-buffer) sibling-id)
(let* ((old (ebox--ensure-layout-snapshot-details (current-buffer) owner-id))
(old-facts (copy-tree old))
(state (ebox--buffer-render-state (current-buffer)))
(snapshots (plist-get state :layout-snapshots))
(runtime-revision (plist-get state :runtime-revision))
(surface-revision (tp-surface-revision ebox-surface--buffer-surface))
(scroll-id (car (plist-get state :scroll-region-ids)))
(scroll (gethash scroll-id ebox--scroll-global-state))
(before (buffer-string))
sibling-seed sibling-seed-facts
(seed-observer
(lambda (candidate-state)
(setq sibling-seed
(gethash sibling-id (plist-get candidate-state
:layout-snapshots))
sibling-seed-facts (copy-tree sibling-seed)))))
(should (equal (if expanded '(inline flow) '(block column))
(plist-get old :display)))
(should (eq (if expanded 'visible 'scroll)
(plist-get (plist-get old :style-signature) :overflow)))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject retained kind replacement"))))
(should (equal
(should-error
(ebox-commit
(current-buffer)
(ebox-child-range-test--replace-snapshot-detail
(current-buffer) expanded)))
'(error "Reject retained kind replacement"))))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (eq snapshots (plist-get state :layout-snapshots)))
(should (eq old (gethash owner-id snapshots)))
(should (equal old-facts old))
(should (= runtime-revision (plist-get state :runtime-revision)))
(should (= surface-revision
(tp-surface-revision ebox-surface--buffer-surface)))
(should (eq scroll (gethash scroll-id ebox--scroll-global-state)))
(should (equal-including-properties before (buffer-string)))
(advice-add 'ebox-surface--render-candidate :before seed-observer)
(unwind-protect
(ebox-commit
(current-buffer)
(ebox-child-range-test--replace-snapshot-detail
(current-buffer) expanded))
(advice-remove 'ebox-surface--render-candidate seed-observer))
(should (ebox--layout-snapshot-detailed-p sibling-seed))
(should (eq sibling-seed
(gethash sibling-id
(plist-get (ebox--buffer-render-state (current-buffer))
:layout-snapshots))))
(should (equal sibling-seed-facts sibling-seed))
(should (= owner-id
(plist-get (ebox--host-ref-node (current-buffer) 'detail)
:node-id)))
(ebox-child-range-test--assert-full-render-equivalent (current-buffer))
(ebox-child-range-test--assert-current-snapshot
(current-buffer) owner-id)
(dolist (ancestor ancestors)
(ebox-child-range-test--assert-current-snapshot
(current-buffer) ancestor))))))))
(ert-deftest ebox-child-range-retained-kind-replacement-allows-mixed-followups ()
"Later title and row paint edits remain local beside disjoint scrolling."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-child-range-test--snapshot-replacement-root))
(ebox--ensure-layout-snapshot-details
(current-buffer)
(plist-get (ebox--host-ref-node (current-buffer) 'detail) :node-id))
(ebox-commit
(current-buffer)
(ebox-child-range-test--replace-snapshot-detail (current-buffer) t))
(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))
(raw (plist-get scroll :content-lines))
(rendered (plist-get scroll :rendered-content-lines))
(identities
(mapcar (lambda (ref)
(cons ref (plist-get (ebox--host-ref-node (current-buffer) ref)
:node-id)))
'(row-1 row-2 detail title status scroll))))
(should scroll-id)
(should (= (length (plist-get state :scroll-region-ids)) 1))
(dolist (selected '(2 1 2))
(let* ((candidate (ebox-candidate-begin (current-buffer)))
(root-renders 0)
(observer (lambda (&rest _arguments) (cl-incf root-renders)))
report)
(ebox-candidate-replace-host-ref
candidate 'title
(ebox-test-text (propertize (format "title-%d" selected)
'face '(:weight bold))
:key 'title :source-identity 'title))
(dolist (row '(1 2))
(let ((ref (intern (format "row-%d" row))))
(ebox-candidate-replace-host-ref
candidate ref
(ebox-test-box :key ref :source-identity ref
:color (if (= row selected) "#123456" "#654321")
(ebox-test-text (format "row-%d" row))))))
(advice-add 'ebox-surface--render-candidate :before observer)
(unwind-protect
(setq report (ebox-commit (current-buffer) candidate))
(advice-remove 'ebox-surface--render-candidate observer))
(ebox-child-range-test--assert-full-render-equivalent (current-buffer))
(dolist (entry identities)
(should (= (cdr entry)
(plist-get (ebox--host-ref-node (current-buffer) (car entry))
:node-id))))
(ert-info ((format "selected=%S root-renders=%S report=%S"
selected root-renders report))
(should (eq (plist-get report :projection-kind) 'mixed-owner-reflow))
(should (zerop root-renders))
(should-not (plist-get report :tp-full-root)))
(let ((next-scroll (gethash scroll-id ebox--scroll-global-state)))
(should (eq raw (plist-get next-scroll :content-lines)))
(should (eq rendered (plist-get next-scroll :rendered-content-lines)))))))))
(ert-deftest ebox-child-range-does-not-reuse-disappeared-key-positionally ()
"A new keyed item must not inherit a removed peer's runtime identity."
(let ((buffer (generate-new-buffer " *ebox-range-key-reentry*")))