perf: publish certified scroll viewports without document planning
Some checks are pending
CI / test (29.1) (push) Waiting to run
CI / test (30.2) (push) Waiting to run
CI / native-build (macos-latest) (push) Waiting to run
CI / native-build (ubuntu-latest) (push) Waiting to run
CI / native-build (windows-latest) (push) Waiting to run
CI / native-msrv (macos-latest) (push) Waiting to run
CI / native-msrv (ubuntu-latest) (push) Waiting to run
CI / native-msrv (windows-latest) (push) Waiting to run

This commit is contained in:
Kinneyzhang 2026-09-11 00:22:59 +08:00
parent efdc35f4af
commit 993e09be6b
8 changed files with 649 additions and 65 deletions

View File

@ -54,6 +54,16 @@ roots may reuse their complete composed content and render only the wrapper.
Pure root offset changes preserve already composed portal and anchor content.
Style, placement, content and display-context changes must invalidate that proof.
Proved scroll-only changes build a complete candidate from the published state
and a local immutable index delta, bypassing general declarative preparation.
Surface stages rich replacement text and exact old/new character ranges, then
returns an authenticated TP commit batch with complete target mount specs;
it does not build a surface plan. Equal text lengths never authorize reuse of
old mounts: newly visible rows may have different owners and interaction tags.
Owner resolution can reuse a private cache while its topology and TP object
indexes remain valid. Attribute updates and layout reflow retain their own
preparation paths; only terminal publication and rollback are shared.
A nested owner can replace its viewport within a proven unchanged allocation,
without rendering unrelated siblings. With one unchanged root scroll ancestor,
the update must also patch that ancestor's retained content, ownership membership
@ -68,6 +78,14 @@ freshly rebound producers. A producer must not mutate a published continuation
before TP commits. This isolation can rebuild the target prefix on a miss;
cached-hit timings do not describe that cost.
The prepared viewport batch is consumed once inside the existing scoped TP
transaction. Subsequent signal-driven evaluation uses the current committed
source and ordinary projection. A proof miss chooses the ordinary path before
publication; a publication error propagates through rollback without retrying
through another renderer. General preparation and surface-plan counters in the
shared evaluator verify the bypass separately from TP bookkeeping and buffer
write time; this path does not establish constant cost in total object count.
Scroll publication still goes through TP and the Ebox participant. Rejection
preserves the old text, properties, offsets, raw and rendered caches, source
nodes, indexes, objects, mounts and report. The focused regression targets are

View File

@ -53,6 +53,13 @@ layered 或带 chrome 的 root 可以复用完整合成内容,只渲染 wrappe
变化保留已经合成的 portal 和 anchor 内容style、placement、content 或 display
context 变化必须使该证明失效。
通过证明的纯滚动变更从已发布状态与局部不可变 index delta 构造完整 candidate
跳过通用声明式准备。Surface 提前生成带属性的替换文本及精确的新旧字符范围,
再通过带完整目标 mount specs 的认证 TP commit batch 发布,不构造 surface plan。
文本等长不能证明旧 mount 可复用:新进入视口的行可能具有不同 owner 与交互 tag。
Topology 与 TP object index 有效时,可复用私有 owner 解析缓存。属性更新与布局
重排仍使用各自的准备路径,只共享最终发布与回滚边界。
Nested owner 在 allocation 不变且通过证明时,可以只替换自己的 viewport避免渲染
无关兄弟节点。若存在一个 offset 不变的 root scroll ancestor还必须更新该祖先的
保留内容、ownership membership 和 line index并将 producer 重新绑定到 candidate
@ -64,6 +71,12 @@ Nested owner 在 allocation 不变且通过证明时,可以只替换自己的
不得在 TP commit 前修改已发布的 continuation。该隔离可能在 miss 时重建目标 prefix
因此 cached-hit 耗时不能代表 miss 的代价。
预备的 viewport batch 在现有 scoped TP transaction 中只消费一次。后续 signal
触发的求值读取当前已提交 source使用普通 projection。证明失败在发布前选择普通
路径;发布错误直接进入 rollback不换另一个 renderer 重试。共享 evaluator 的
通用准备与 surface-plan 计数分别验证这两项工作已跳过TP bookkeeping 与实际
buffer 写入另行测量。该路径并不保证耗时与总 object 数无关。
Scroll publication 仍经过 TP 与 Ebox participant。发布失败时旧文本、属性、offset、
raw/rendered cache、source node、index、object、mount 和 report 均保持不变。聚焦回归
入口为 `make layer-tests`、`make nested-scroll-tests`、`make scroll-cold-tests`

View File

@ -233,6 +233,17 @@
:rollback discard-unreferenced-candidates
:rebuild-proof reproject-owner-native-hover-face
:cleanup garbage-collect-unreferenced-faces)
(:id scroll-viewport-owner-cache
:storage (:scroll-viewport-owner-cache)
:current-contract candidate-private-resolved-owner-table
:target-contract discardable-derived-values
:category disposable-cache
:owner ebox-surface
:mutation-api copy-before-candidate-fill
:generation-binding unchanged-parent-region-and-tp-object-indexes
:rollback discard-candidate-cache
:rebuild-proof resolve-rich-line-roles-against-retained-topology
:cleanup ordinary-projection-or-generation-replacement)
(:id derived-caches
:storage (ebox--box-content-render-cache ebox--char-width-cache
ebox--display-signature-cache ebox--face-height-width-cache

View File

@ -184,6 +184,7 @@ the winning client state; otherwise only the candidate is disposable."
(owner key compute value dependencies &rest options))
(declare-function ebox--record-render-output-provenance
"ebox-render-context" (rendered))
(declare-function ebox--strip-paint-origins! "ebox-render-context" (string))
(declare-function ebox--render-owned-text-value-p
"ebox-render-context" (property value &optional registry))
(declare-function ebox-incremental--hash-snapshot
@ -2424,10 +2425,13 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil."
(signals (car signals-result))
(signals-created-p (cdr signals-result))
(producer
(if (plist-get state-overrides :scroll-viewport-batch)
(ebox-surface--scroll-viewport-producer
old-state state-overrides signals)
(ebox-surface-producer
source old-state t state-overrides signals projection-kind
source-isolated-p
(plist-get state-overrides :source-path-copied-p)))
(plist-get state-overrides :source-path-copied-p))))
success native-settled-p)
(when (and (null on-mismatch)
(or (plist-get state-overrides :owner-scoped-proofs)
@ -5275,6 +5279,173 @@ copies where needed. Detached owners keep their fractional-origin guard."
0 (length output) (list 'ebox-scroll-window region-id) output))
output))))
(defun ebox-surface--prepare-scroll-viewport (buffer previous-state state)
"Stage STATE's exact rich viewport without document or surface planning.
PREVIOUS-STATE remains the published generation in BUFFER. Existing root
wrapper and nested allocation proofs select the output; a proof miss returns
nil before any TP publication. The resulting ordinary TP batch contains only
the proved replacement coordinates, including an enclosing cache's separate
content-coordinate update when present."
(with-current-buffer buffer
(save-restriction
(widen)
(let* ((nested (plist-get state :nested-scroll-patch-proof))
(output
(if nested
(when (and (not (plist-get previous-state :fractional-pixel-output-p))
(or (not (plist-get nested :scroll-content-region-id))
(plist-get state :scroll-content-proof)))
(unless (plist-get state :owner-scoped-proofs)
(plist-put state :owner-scoped-proofs (list nested)))
(let ((ebox-surface--integral-local-output-required-p t))
(catch 'ebox/fractional-local-output
(ebox-surface--span-patch-output buffer previous-state state))))
(ebox-surface--scroll-patch-output buffer state t))))
(when (and (stringp output)
(not (plist-get state :paint-property-contributions)))
(unless (plist-get state :scroll-patch-lines)
(plist-put state :scroll-viewport-fragments
(or (plist-get state :span-patch-fragment-data)
(ebox-surface--rendered-fragments output))))
(ebox--strip-paint-origins! output)
(let* ((base-extent (- (point-max) (point-min)))
(target-extent (length output))
(coordinates
(or (plist-get state :content-coordinate-patches)
(list (list :old-start 0 :old-end base-extent
:new-start 0 :new-end target-extent))))
(patches
(mapcar
(lambda (patch)
(append patch
(list :replacement
(substring output (plist-get patch :new-start)
(plist-get patch :new-end)))))
coordinates))
(batch
(condition-case nil
(tp-commit-batch-create
:base-revision (plist-get state :runtime-revision)
:target-revision (1+ (plist-get state :runtime-revision))
:base-extent base-extent :target-extent target-extent
:patches patches :coordinate-patches coordinates)
(tp-surface-error nil))))
(when batch
(plist-put state :scroll-viewport-batch batch)
output)))))))
(defun ebox-surface--scroll-viewport-owner-cache (previous-state state)
"Return a private owner-resolution cache for STATE's unchanged topology.
Reuse requires PREVIOUS-STATE's exact parent, region and TP object indexes.
The scroll-only source proof preserves their meanings when rebinding copied
Boxes; ordinary document projection discards this optional derived cache."
(let* ((old (plist-get previous-state :scroll-viewport-owner-cache))
(keys '(:parent-table :region-node-table
:surface-node-object-table :region-surface-object-table))
(valid (and old
(cl-every (lambda (key)
(eq (plist-get old key) (plist-get previous-state key)))
keys)))
(cache (list :owners
(if valid (copy-hash-table (plist-get old :owners))
(make-hash-table :test #'equal)))))
(dolist (key keys)
(plist-put cache key (plist-get state key)))
cache))
(defun ebox-surface--scroll-viewport-result (context previous-state state output batch)
"Publish STATE's proved viewport OUTPUT and BATCH in the active CONTEXT.
Reuse PREVIOUS-STATE's keyed topology and resolve rich roles against that
unchanged topology. Every batch supplies complete target ownership, since
equal character lengths do not imply equal visible owners or interaction."
(let* ((surface-root (tp-object-ensure context nil ebox-surface--root-key 'ebox/surface))
(node-root (tp-object-ensure context surface-root ebox-surface--nodes-key 'ebox/nodes))
(fragment-root (tp-object-ensure context surface-root
ebox-surface--fragments-key 'ebox/fragments))
(text-leaf (tp-object-ensure context fragment-root ebox-surface--text-key 'ebox/text))
(node-objects (plist-get state :surface-node-object-table))
(region-objects (plist-get state :region-surface-object-table))
(lines (plist-get state :scroll-patch-lines))
(fragments
(unless lines
(plist-get state :scroll-viewport-fragments)))
(_cache
(plist-put state :scroll-viewport-owner-cache
(ebox-surface--scroll-viewport-owner-cache previous-state state)))
(owned-ranges
(if lines
(ebox-surface--scroll-owned-ranges
context text-leaf lines state node-objects region-objects nil)
(ebox-surface--owned-ranges
context text-leaf fragments state node-objects region-objects nil)))
(length (length output))
(records
(list (list :object text-leaf :start 0 :end length :props nil :tags nil :leaf t)
(list :object fragment-root :start 0 :end length :props nil :tags nil :leaf nil)
(list :object surface-root :start 0 :end length :props nil :tags nil :leaf nil))))
(tp-object-reuse-subtree context node-root)
(plist-put state :surface-owned-ranges owned-ranges)
(plist-put state :surface-fragments
(if lines (list :scroll-lines (copy-sequence lines))
(ebox-surface--fragment-metadata fragments state)))
(dolist (key '(:scroll-viewport-output :scroll-viewport-batch :scroll-viewport-fragments
:content-coordinate-patches :content-base-extent
:span-patch-fragment-data :span-patch-content-p
:span-patch-retained-owned-ranges :scroll-patch-lines
:nested-scroll-patch-proof :scroll-content-proof
:owner-scoped-proofs :scroll-state-transaction
:scroll-fast-window-p :scroll-patch-region-id :span-patch-owner-id
:preserve-scroll-producer-region-ids :previous-surface-owned-ranges))
(cl-remf state key))
(plist-put state :retained-scroll-content-p nil)
(tp-commit-batch-result-create
context batch :mount-specs (ebox-surface--commit-mount-specs records owned-ranges)
:client-state state)))
(defun ebox-surface--scroll-viewport-producer (previous-state state signals)
"Return a scroll-only producer using the existing scoped transaction.
STATE owns one prepared viewport batch after PREVIOUS-STATE. Subscribe to
SIGNALS just like ordinary projection, but consume that batch only once.
A later reactive evaluation renders the then-current source normally rather
than replaying old coordinates, properties or revision authority."
(let ((output (plist-get state :scroll-viewport-output))
(batch (plist-get state :scroll-viewport-batch))
(offsets (ebox-surface--scroll-offsets (plist-get state :scroll-state-table))))
(lambda (context)
(let* ((buffer (ebox-surface--signals-buffer signals))
(surface (ebox-surface--live-buffer-surface buffer))
(current (tp-surface-client-state surface))
(prepared state)
(base previous-state)
(rendered output)
(prepared-batch batch)
(expected-offsets offsets)
(_consume
;; A producer remains subscribed after this evaluation. Keep
;; only current signals in that closure, on success or fallback;
;; later evaluations must not retain an obsolete document.
(setq state nil previous-state nil output nil batch nil offsets nil))
(axes (plist-get (or prepared current) :viewport-dependent-node-id-axes))
(display (tp-signal-read (ebox-surface--signals-display signals)))
(width (funcall (if (car axes) #'tp-signal-read #'tp-signal-peek)
(ebox-surface--signals-viewport-width signals)))
(height (funcall (if (cdr axes) #'tp-signal-read #'tp-signal-peek)
(ebox-surface--signals-viewport-height signals)))
(scroll (tp-signal-read (ebox-surface--signals-scroll signals))))
(with-current-buffer buffer
(if (and prepared (eq current base)
(equal display (plist-get prepared :display-signature))
(equal width (plist-get prepared :viewport-width))
(equal height (plist-get prepared :viewport-height))
(equal scroll expected-offsets))
(ebox-surface--scroll-viewport-result
context base prepared rendered prepared-batch)
(ebox-surface--project
context (ebox-tree-copy-node-structure (plist-get current :root-node))
current t (list :runtime-revision (tp-surface-revision surface)
:source-base-index (plist-get current :source-index))
signals nil t nil)))))))
(defun ebox-surface--runtime-index-ready-p (state)
"Return non-nil when STATE carries a complete prepared runtime index."
(let* ((root (plist-get state :root-node))
@ -5766,7 +5937,9 @@ VISIT-RUNS receives one callback accepting ROLE-IDS, START, END, and optional
layout dependency region ids.
Adjacent ranges merge only when both their owner and opaque tags match."
(let ((active (make-hash-table :test #'eq))
(owner-cache (make-hash-table :test #'equal))
(owner-cache
(or (plist-get (plist-get state :scroll-viewport-owner-cache) :owners)
(make-hash-table :test #'equal)))
ranges)
(funcall
visit-runs

138
ebox.el
View File

@ -3393,10 +3393,7 @@ A miss owns its subtree, indexes, cache lines and producers before staging."
(or (and (ebox--scroll-state-retained-window-ready-p
staged-scroll-state) t)
(and (plist-get runtime :nested-scroll-patch-proof) t)))
(commit-input
(let ((ebox--scroll-global-state table))
(ebox-incremental-prepare-scoped-commit
buffer root
(report-overrides
(append
(ebox--constraint-change-report-props
(ebox--region-constraint-change
@ -3411,11 +3408,20 @@ A miss owns its subtree, indexes, cache lines and producers before staging."
:scroll-state-transaction t
:scroll-patch-fast-p scroll-patch-fast-p
:native-scroll-materialize-p
(plist-get transition :native-materialize-p)))
t)))
(plist-get transition :native-materialize-p))))
(viewport-commit
(unless (plist-get transition :native-materialize-p)
(ebox--surface-scroll-viewport-commit
buffer region-id runtime report-overrides)))
(commit-input
(or viewport-commit
(let ((ebox--scroll-global-state table))
(ebox-incremental-prepare-scoped-commit
buffer root report-overrides t))))
(_staged-cache
(unless viewport-commit
(ebox--surface-restore-staged-scroll-cache
commit-input region-id staged-scroll-state))
commit-input region-id staged-scroll-state)))
(scope-node-ids
(or (plist-get commit-input :scope-node-ids)
(list owner-id)))
@ -3455,6 +3461,124 @@ A miss owns its subtree, indexes, cache lines and producers before staging."
(ignore surface)
(plist-get transition :result))))
(defun ebox--surface-scroll-viewport-commit
(buffer region-id runtime report)
"Prepare BUFFER's REGION-ID retained viewport from isolated RUNTIME.
Return an ordinary scoped commit input, or nil before publication when the
document, display context or allocation needs general preparation. REPORT
describes the scroll intent; Surface supplies exact text and ownership.
The local index delta preserves immutable source facts and rebinds copied
Boxes without publishing RUNTIME's deliberately sparse staging indexes."
(let* ((base (plist-get runtime :base-state))
(root (plist-get runtime :root))
(old-root (plist-get base :root-node))
(owner (plist-get report :owner-id))
(root-p (equal owner (plist-get root :node-id)))
(nested-proof (plist-get runtime :nested-scroll-patch-proof))
(old-scroll (gethash region-id (plist-get base :scroll-state-table))))
(when (and owner old-scroll
(ebox-surface--runtime-index-ready-p base)
(hash-table-p (plist-get base :surface-node-object-table))
(hash-table-p (plist-get base :region-surface-object-table))
(or root-p nested-proof)
(not (plist-get base :native-sync-session))
(not (plist-get base :native-sync-confirmed-p))
(not (plist-get old-scroll :lazy-scroll-prefix-dirty))
(not (plist-get old-scroll :lazy-scroll-window-refresh-required))
(not (plist-get old-scroll :native-reflow-target-prefix-p))
(not (plist-get old-scroll :native-reflow-materialize-p))
(zerop (ebox-tree-author-style-count old-root))
(zerop (ebox-tree-author-style-count root))
(equal (plist-get base :display-signature)
(ebox--current-display-signature))
(eq (not (null (plist-get base :cascade-active-p)))
(not (null (ebox-style-cascade-active-p))))
(or (not (plist-get base :cascade-active-p))
(equal (plist-get base :stylesheet-signature)
(ebox-surface--stylesheet-signature))))
(let* ((delta (ebox-incremental--candidate-local-index-delta base root))
(dirty (plist-get delta :dirty-set))
(index (plist-get delta :index)))
(when (and (null (plist-get delta :removed-node-ids))
(cl-every
(lambda (entry)
(and (equal owner (plist-get entry :node-id))
(not (plist-get entry :children))
(cl-every
(lambda (key)
(memq key '(:scroll-offset :ebox-scroll-offset-controlled-p)))
(plist-get entry :changed-keys))))
dirty))
(let* ((candidate (ebox--render-state-install-index (copy-sequence base) index))
(table (plist-get runtime :scroll-table))
(prepared (list :root root :dirty-set dirty :scroll-state-table table))
(structural-caches
(ebox-incremental--candidate-structural-caches base index dirty delta)))
(dolist (key '(:owner-scoped-proofs :scroll-content-proof
:content-coordinate-patches :content-base-extent
:span-patch-fragment-data :span-patch-content-p
:span-patch-retained-owned-ranges :scroll-patch-lines
:paint-property-contributions :previous-surface-owned-ranges
:mixed-owner-proof :mixed-owner-fragment-data
:mixed-owner-content-p :formatting-context-reflow-proof
:layer-recompose-p :native-scroll-materialize-p
:native-buffer-scroll-p :native-render-fallback))
(cl-remf candidate key))
(ebox-incremental--isolate-candidate-caches candidate)
(plist-put candidate :root-node root)
(plist-put candidate :scroll-state-table table)
(plist-put candidate :runtime-index-prepared-p t)
(plist-put candidate :runtime-revision
(1+ (or (plist-get base :runtime-revision) 0)))
(plist-put candidate :selector-index-stale-p
(plist-get index :selector-index-stale-p))
(plist-put candidate :layout-snapshots (make-hash-table :test 'equal))
(plist-put candidate :layout-snapshots-complete-p nil)
(plist-put candidate :layout-snapshot-detail-generation
(1+ (or (plist-get base :layout-snapshot-detail-generation) 0)))
(plist-put candidate :retained-allocation-certificates nil)
(plist-put candidate :reflow-prewarm-scratch nil)
;; Quantization registers nested mutable property-value tables.
;; A viewport candidate owns that registry before touching even
;; one fractional carrier, including when TP later rejects it.
(plist-put candidate :render-owned-text-values (make-hash-table :test #'eq))
(plist-put candidate :flex-content-min-widths (make-hash-table :test #'eq))
(dolist (key '(:render-signature-cache :viewport-height-dependent-subtree-cache))
(plist-put candidate key (plist-get structural-caches key)))
(plist-put candidate :projection-kind 'scroll-patch)
(plist-put candidate :scroll-state-transaction t)
(plist-put candidate :scroll-patch-region-id region-id)
(plist-put candidate :span-patch-owner-id owner)
(plist-put candidate :scroll-fast-window-p t)
(plist-put candidate :retained-scroll-content-p root-p)
(plist-put candidate :preserve-scroll-producer-region-ids (list region-id))
(plist-put candidate :nested-scroll-patch-proof nested-proof)
(let ((ebox--render-source-index (plist-get candidate :source-index))
(ebox--render-source-generations
(list (plist-get candidate :source-index) (plist-get base :source-index)))
(ebox--render-source-states (list candidate base)))
(when (plist-get nested-proof :scroll-content-region-id)
(when-let* ((proof (ebox-incremental--scroll-content-span-proof
buffer base prepared candidate owner)))
(plist-put candidate :owner-scoped-proofs (list proof))
(plist-put candidate :scroll-content-proof
(plist-get proof :scroll-content-proof))))
(when-let* (((or (not root-p)
(not (or (plist-get base :layered-p)
(plist-get base :root-portals-p)))
(ebox-incremental--layer-root-scroll-p
base candidate prepared owner)))
(output (ebox-surface--prepare-scroll-viewport buffer base candidate)))
(plist-put candidate :scroll-viewport-output output)
(list :root root :scope-node-ids (list owner)
:report-base
(ebox-incremental--plist-overlay
(ebox-incremental--layout-owner-report
prepared (list (ebox--patch-op 'span-patch owner :dirty dirty)) candidate)
(append report '(:projection-kind scroll-patch :full-rerender nil)))
:projection-kind 'scroll-patch
:state-overrides candidate)))))))))
(defun ebox--surface-restore-staged-scroll-cache
(commit-input region-id staged-state)
"Restore REGION-ID STAGED-STATE cache fields in COMMIT-INPUT."

View File

@ -9,6 +9,27 @@
(require 'ert)
(require 'cl-lib)
(require 'ebox)
(require 'ebox-layer-publication-tests)
(defmacro ebox-layer-scroll-test--without-general-preparation (&rest body)
"Run cached publication BODY without document preparation or surface scans."
(declare (indent 0) (debug t))
`(let ((stages '(ebox-incremental-prepare-scoped-commit
ebox-incremental--prepare-declarative-runtime
ebox--runtime-index ebox-surface--surface-plan))
counters advices)
(unwind-protect
(progn
(dolist (stage stages)
(let* ((counter (cons stage 0))
(advice (lambda (&rest _) (cl-incf (cdr counter)))))
(push counter counters)
(push (cons stage advice) advices)
(advice-add stage :before advice)))
,@body)
(dolist (entry advices) (advice-remove (car entry) (cdr entry))))
(should (equal (nreverse counters)
(mapcar (lambda (stage) (cons stage 0)) stages)))))
(defmacro ebox-layer-scroll-test--with-buffer (&rest body)
"Run BODY in an isolated Elisp scroll buffer without native window scrolling."
@ -55,6 +76,24 @@
(root (plist-get runtime :root-node)))
(gethash (plist-get root :region-id) (plist-get runtime :scroll-state-table))))
(defun ebox-layer-scroll-test--ordinary-input ()
"Build an ordinary root with fractional carriers in retained physical rows."
(ebox-build
`(column :id "root" :width (vw 100) :height (vh 100) :overflow scroll
,@(cl-loop for index below 80
collect `(box :padding-left (px 1.5)
,(format "ROW%02d" index))))))
(defun ebox-layer-scroll-test--materialize-root ()
"Fully cache the root and bootstrap its retained physical rows."
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(state (ebox-layer-scroll-test--root-state))
(region (plist-get (plist-get state :box) :region-id)))
(setq state (ebox--scroll-state-materialize-lines region state))
(puthash region state (plist-get runtime :scroll-state-table))
(should (= 1 (ebox--scroll-region-by region 1 1)))
(should (= -1 (ebox--scroll-region-by region -1 1)))))
(defun ebox-layer-scroll-test--portal-chain-input ()
"Build two visible anchored root projections through a clipped logical owner."
(ebox-build
@ -93,6 +132,7 @@
(unless (eq candidate root)
(ebox-put candidate :scroll-offset offset)
(ebox-put candidate :ebox-scroll-offset-controlled-p t))))
(mounts (ebox-layer-publication-test--mount-signature (current-buffer)))
actual fresh)
(setq actual (buffer-string))
;; Bind only the first layout invocation, leaving nested scroll offsets
@ -102,7 +142,16 @@
(lambda (candidate)
(unless first (setq first t) (funcall set-offset candidate))
(funcall render candidate))))
(setq fresh (ebox-render input))))
(with-temp-buffer
(unwind-protect
(progn
(ebox-render-to-buffer (current-buffer) input)
(setq fresh (buffer-string))
(should (equal mounts
(ebox-layer-publication-test--mount-signature
(current-buffer)))))
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer)))))))
(should (equal (substring-no-properties actual) (substring-no-properties fresh)))
(should (= (length actual) (length fresh)))
(dotimes (position (length actual))
@ -120,12 +169,18 @@
(let* ((state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id))
(content (plist-get state :content-lines))
(objects (copy-hash-table
(plist-get (ebox--buffer-render-state (current-buffer))
:surface-node-object-table)))
(root-renders 0)
(render (symbol-function 'ebox-surface--render-candidate)))
(when (eq chrome 'fractional)
(should (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p)))
(dolist (delta '(1 1 -1 -1))
(ebox-layer-scroll-test--without-general-preparation
(let ((revision (ebox-surface-buffer-revision (current-buffer)))
(before (buffer-string)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf root-renders)
@ -136,6 +191,16 @@
(should (eq (plist-get (ebox-buffer-update-report (current-buffer))
:projection-kind)
'scroll-patch)))
(should (= (1+ revision)
(ebox-surface-buffer-revision (current-buffer))))
(should-not (equal-including-properties before (buffer-string))))
(maphash
(lambda (id object)
(should (eq object
(gethash id (plist-get
(ebox--buffer-render-state (current-buffer))
:surface-node-object-table)))))
objects))
(ebox-layer-scroll-test--assert-fresh))
(should (= root-renders 0))))))
@ -185,22 +250,53 @@
(ert-deftest ebox-layer-scroll-rejected-publication-keeps-cache-and-input ()
"Both publication failure stages preserve composed cache and current offset."
(dolist (failure-step '(text client-state))
(pcase-dolist (`(,fixture ,failure-step)
'((layer text) (layer client-state)
(ordinary text) (ordinary client-state)))
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-scroll-test--input 'fractional))
(current-buffer) (if (eq fixture 'layer)
(ebox-layer-scroll-test--input 'fractional)
(ebox-layer-scroll-test--ordinary-input)))
(when (eq fixture 'ordinary) (ebox-layer-scroll-test--materialize-root))
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id))
(content (plist-get state :content-lines))
(snapshot (ebox-surface-buffer-snapshot (current-buffer)))
(revision (ebox-surface-buffer-revision (current-buffer)))
(before (buffer-string)))
(mounts (ebox-layer-publication-test--mount-signature (current-buffer)))
(before (buffer-string))
(owned (plist-get runtime :render-owned-text-values))
(membership
(let (properties)
(maphash
(lambda (property values)
(let (entries)
(maphash (lambda (value member) (push (cons value member) entries))
values)
(push (list property values entries) properties)))
owned)
properties))
rejected-step)
(ebox-layer-scroll-test--without-general-preparation
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step) (error "Reject cached root scroll")))))
(should-error (ebox--scroll-region-by region-id 1 1)))
(when (eq step failure-step)
(setq rejected-step step)
(error "Reject cached root scroll")))))
(should-error (ebox--scroll-region-by region-id 1 1))))
(should (eq rejected-step failure-step))
(should (eq runtime (ebox--buffer-render-state (current-buffer))))
(should (eq owned (plist-get runtime :render-owned-text-values)))
(should (= (hash-table-count owned) (length membership)))
(dolist (property membership)
(let ((values (gethash (car property) owned))
(entries (nth 2 property)))
(should (eq values (nth 1 property)))
(should (= (hash-table-count values) (length entries)))
(dolist (entry entries)
(should (eq (gethash (car entry) values 'missing) (cdr entry))))))
(should (eq state (ebox-layer-scroll-test--root-state)))
(should (eq content (plist-get state :content-lines)))
(let* ((after (ebox-surface-buffer-snapshot (current-buffer)))
@ -210,8 +306,85 @@
(should same-input)
(should (= (plist-get snapshot :mount-id) (plist-get after :mount-id))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal mounts
(ebox-layer-publication-test--mount-signature (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(ebox-layer-scroll-test--without-general-preparation
(should (= 1 (ebox--scroll-region-by region-id 1 1))))
(should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer))))
(ebox-layer-scroll-test--assert-fresh)))))
(ert-deftest ebox-layer-scroll-reactive-width-change-renders-current-viewport ()
"A retained cached-scroll producer republishes current width without replay."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-layer-scroll-test--input 'fractional))
(ebox-region-update "root" :width '(vw 100))
(let* ((region (plist-get (plist-get (ebox-layer-scroll-test--root-state) :box)
:region-id))
(width (ebox-surface--signals-viewport-width ebox-surface--context-signals))
(publish (symbol-function 'ebox-surface--scroll-viewport-result))
(prepared-publications 0))
(cl-letf (((symbol-function 'ebox-surface--scroll-viewport-result)
(lambda (&rest arguments)
(cl-incf prepared-publications)
(apply publish arguments))))
(ebox-layer-scroll-test--without-general-preparation
(should (= 1 (ebox--scroll-region-by region 1 1))))
(should (= prepared-publications 1))
(let ((revision (ebox-surface-buffer-revision (current-buffer)))
(before (buffer-string)))
(should (= (tp-signal-subscriber-count width) 1))
(tp-with-transaction (tp-signal-set width 30))
(should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer))))
(should (= (plist-get (ebox--buffer-render-state (current-buffer))
:viewport-width) 30))
(should (= (plist-get (ebox-layer-scroll-test--root-state) :scroll-offset) 1))
(should-not (equal-including-properties before (buffer-string)))
(should (= prepared-publications 1))
(ebox-layer-scroll-test--assert-fresh))))))
(ert-deftest ebox-layer-scroll-first-guard-miss-releases-batch-and-uses-current-axes ()
"A declined prepared producer releases its candidate and follows current axes."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-layer-scroll-test--input 'fractional))
(ebox-region-update "root" :width '(vw 100))
(let* ((region (plist-get (plist-get (ebox-layer-scroll-test--root-state) :box)
:region-id))
(surface ebox-surface--buffer-surface)
(signals ebox-surface--context-signals)
(width (ebox-surface--signals-viewport-width signals))
(make-producer (symbol-function 'ebox-surface--scroll-viewport-producer))
(retired (make-hash-table :test #'eq :weakness 'key))
retained-producer)
(cl-letf (((symbol-function 'ebox-surface--scroll-viewport-producer)
(lambda (previous prepared context-signals)
(let ((declined (copy-sequence prepared)))
;; Invalidate only the captured certificate, before TP's
;; first evaluation, leaving the live display unchanged.
(plist-put declined :display-signature 'stale-display)
(puthash declined t retired)
(puthash (plist-get declined :scroll-viewport-batch) t retired)
(setq retained-producer
(funcall make-producer previous declined context-signals)))))
((symbol-function 'ebox-surface--scroll-viewport-result)
(lambda (&rest _) (ert-fail "Replayed a declined viewport batch"))))
(should (= 1 (ebox--scroll-region-by region 1 1))))
(should retained-producer)
(ebox-layer-scroll-test--assert-fresh)
(garbage-collect)
(should (= (hash-table-count retired) 0))
(ebox-region-update "root" :width '(ch 20))
(should (= (tp-signal-subscriber-count width) 0))
(let ((revision (ebox-surface-buffer-revision (current-buffer))))
(cl-letf (((symbol-function 'ebox-surface--scroll-viewport-result)
(lambda (&rest _) (ert-fail "Replayed a retired viewport batch"))))
(tp-surface-update surface retained-producer))
(should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer))))
(should (= (tp-signal-subscriber-count width) 0))
(should (= (tp-signal-subscriber-count
(ebox-surface--signals-display signals)) 1))
(should (= (tp-signal-subscriber-count
(ebox-surface--signals-scroll signals)) 1))
(ebox-layer-scroll-test--assert-fresh)))))
(ert-deftest ebox-layer-scroll-visible-root-dependencies-retain-composed-cache ()
@ -322,11 +495,7 @@
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(column :id "root" :width (vw 100) :height (vh 100) :overflow scroll
,@(cl-loop for index below 80
collect `(box :padding-left (px 1.5)
,(format "ROW%02d" index))))))
(ebox-layer-scroll-test--ordinary-input))
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id)))
@ -339,10 +508,25 @@
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(should (= -1 (ebox--scroll-region-by region-id -1 1)))
(should (plist-get (ebox-layer-scroll-test--root-state) :rendered-content-lines))
(dolist (delta '(1 1 -1 -1))
(let ((revision (ebox-surface-buffer-revision (current-buffer)))
(before (buffer-string))
(objects (copy-hash-table
(plist-get (ebox--buffer-render-state (current-buffer))
:surface-node-object-table))))
(ebox-layer-scroll-test--without-general-preparation
(cl-letf (((symbol-function 'ebox-surface--render-candidate-node)
(lambda (&rest _) (ert-fail "Cached ordinary scroll rendered wrapper"))))
(should (= 1 (ebox--scroll-region-by region-id 1 1))))
(ebox-layer-scroll-test--assert-fresh))))
(should (= delta (ebox--scroll-region-by region-id delta 1)))))
(should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer))))
(should-not (equal-including-properties before (buffer-string)))
(maphash
(lambda (id object)
(should (eq object
(gethash id (plist-get (ebox--buffer-render-state (current-buffer))
:surface-node-object-table)))))
objects)
(ebox-layer-scroll-test--assert-fresh))))))
(provide 'ebox-layer-scroll-tests)
;;; ebox-layer-scroll-tests.el ends here

View File

@ -387,6 +387,11 @@
:proposed-category disposable-cache :owner ebox-incremental :authority ebox
:lifetime buffer-runtime :rollback copy-on-candidate
:rebuild viewport-proof :cleanup clear-cache-or-buffer-kill)
(:id buffer-runtime/scroll-viewport-owner-cache
:storage (:state-key :scroll-viewport-owner-cache)
:proposed-category disposable-cache :owner ebox-surface :authority ebox
:lifetime unchanged-topology-and-object-indexes :rollback copy-on-candidate
:rebuild rich-line-owner-resolution :cleanup ordinary-projection)
(:id buffer-runtime/render-owned-text-values
:storage (:state-key :render-owned-text-values)
:proposed-category generation-bound-mutable :owner ebox-surface :authority ebox

View File

@ -10,6 +10,26 @@
(require 'cl-lib)
(require 'ebox)
(defmacro ebox-nested-scroll-test--without-general-preparation (&rest body)
"Run cached publication BODY without document preparation or surface scans."
(declare (indent 0) (debug t))
`(let ((stages '(ebox-incremental-prepare-scoped-commit
ebox-incremental--prepare-declarative-runtime
ebox--runtime-index ebox-surface--surface-plan))
counters advices)
(unwind-protect
(progn
(dolist (stage stages)
(let* ((counter (cons stage 0))
(advice (lambda (&rest _) (cl-incf (cdr counter)))))
(push counter counters)
(push (cons stage advice) advices)
(advice-add stage :before advice)))
,@body)
(dolist (entry advices) (advice-remove (car entry) (cdr entry))))
(should (equal (nreverse counters)
(mapcar (lambda (stage) (cons stage 0)) stages)))))
(defmacro ebox-nested-scroll-test--with-buffer (&rest body)
"Run BODY with an isolated mounted surface and the Elisp renderer."
(declare (indent 0) (debug t))
@ -87,7 +107,7 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(should (equal (substring-no-properties actual)
(buffer-substring-no-properties (point-min) (point-max))))
(dotimes (position (length actual))
(dolist (property '(face display help-echo mouse-face pointer keymap))
(dolist (property '(face font-lock-face display help-echo mouse-face pointer keymap))
(should (equal (get-text-property position property actual)
(get-text-property (1+ position) property)))))
(should (equal mounts (ebox-nested-scroll-test--mounts))))
@ -193,10 +213,28 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(ebox-nested-scroll-test--parity)
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll (gethash right (plist-get state :scroll-state-table)))
(lines (plist-get scroll :content-lines)))
(lines (plist-get scroll :content-lines))
(revision (ebox-surface-buffer-revision (current-buffer)))
(objects (copy-hash-table (plist-get state :surface-node-object-table))))
(dolist (delta '(1 1 -1))
(ebox-nested-scroll-test--without-general-preparation
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Independent scroll rendered root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) left 1 nil) 1)))
(should (= (ebox--surface-scroll-region-by
(current-buffer) left delta nil) delta))))
(cl-incf revision)
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(let ((next (gethash right (plist-get (ebox--buffer-render-state
(current-buffer)) :scroll-state-table))))
(should (= (plist-get next :scroll-offset) 2))
(should (eq (plist-get next :content-lines) lines)))
(ebox-nested-scroll-test--parity))
(maphash
(lambda (id object)
(should (eq object
(gethash id (plist-get (ebox--buffer-render-state (current-buffer))
:surface-node-object-table)))))
objects)
(let ((next (gethash right (plist-get (ebox--buffer-render-state
(current-buffer)) :scroll-state-table))))
(should (= (plist-get next :scroll-offset) 2))
@ -204,10 +242,14 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(ebox-nested-scroll-test--parity)
(ebox-region-update "line-1" :content "UPDATED")
(should (string-match-p "UPDATED" (buffer-string)))
(should (= (plist-get (ebox--scroll-get-state right) :scroll-offset) 2))
(ebox-nested-scroll-test--parity)
(ebox-rerender-buffer-with-context (current-buffer) 120 35)
(should (= (plist-get (ebox--scroll-get-state left) :scroll-offset) 1))
(should (= (plist-get (ebox--scroll-get-state right) :scroll-offset) 2))
(ebox-nested-scroll-test--parity)
(should (= (ebox--surface-scroll-region-by (current-buffer) left -1 nil) -1))
(ebox-nested-scroll-test--without-general-preparation
(should (= (ebox--surface-scroll-region-by (current-buffer) left -1 nil) -1)))
(ebox-nested-scroll-test--parity))))
(ert-deftest ebox-nested-scroll-rejection-restores-local-and-fallback-state ()
@ -224,6 +266,7 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(before (buffer-string))
(mounts (ebox-nested-scroll-test--mounts t))
(revision (ebox-surface-buffer-revision (current-buffer)))
rejected-step
(root-renders 0)
(render (symbol-function 'ebox-surface--render-candidate)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
@ -231,9 +274,16 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(cl-incf root-renders) (apply render arguments))))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state) (error "Reject nested scroll")))))
(when (eq step 'client-state)
(setq rejected-step step)
(error "Reject nested scroll"))))
(attempt (lambda ()
(should-error (ebox--surface-scroll-region-by
(current-buffer) region 1 nil))))
(current-buffer) region 1 nil)))))
(if fallback (funcall attempt)
(ebox-nested-scroll-test--without-general-preparation
(funcall attempt)))))
(should (eq rejected-step 'client-state))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))
@ -241,7 +291,11 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(should (= (plist-get scroll :scroll-offset) 0))
(should (= (or (ebox-get box :scroll-offset) 0) 0))
(should (if fallback (> root-renders 0) (= root-renders 0)))
(if fallback
(should (= (ebox--surface-scroll-region-by (current-buffer) region 1 nil) 1))
(ebox-nested-scroll-test--without-general-preparation
(should (= (ebox--surface-scroll-region-by (current-buffer) region 1 nil) 1))))
(should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer))))
(ebox-nested-scroll-test--parity)))))
(ert-deftest ebox-nested-scroll-updates-enclosing-cache-and-role-membership ()
@ -253,12 +307,14 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(outer (ebox-nested-scroll-test--region "root"))
(hidden (ebox-nested-scroll-test--region "line-0"))
(visible (ebox-nested-scroll-test--region "line-5")))
(ebox-nested-scroll-test--without-general-preparation
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Inner scroll rendered enclosing root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1)))
(should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1))))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll (gethash outer (plist-get state :scroll-state-table)))
(members (ebox--scroll-state-ensure-content-region-id-set scroll)))
(should (= (plist-get scroll :scroll-offset) 0))
(should (eq (plist-get scroll :box) (plist-get state :root-node)))
(should-not (gethash hidden members))
(should (gethash visible members))