From b824467791af36e08527697be01c0d6d05fb4093 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 08:44:29 +0800 Subject: [PATCH] Retain native node deltas across confirmed sessions --- ebox-incremental.el | 7 +- ebox-native-commit.el | 5 + ebox-native-reflow.el | 531 ++++++++- ebox-state-contract.el | 6 + native/c/ebox_module.c | 2 +- native/src/layout.rs | 1717 +++++++++++++++++++++++++-- native/src/lib.rs | 347 +++++- tests/ebox-commit-tests.el | 436 +++++++ tests/ebox-m0a-inventory-fixture.el | 49 + tests/ebox-state-contract-tests.el | 22 +- tests/ebox-surface-tests.el | 120 +- 11 files changed, 3040 insertions(+), 202 deletions(-) diff --git a/ebox-incremental.el b/ebox-incremental.el index a5c0014..7910bb9 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -9974,6 +9974,10 @@ role, and outside-complement compatibility before publication." (plist-put overrides :native-inherited-dirty-node-ids (plist-get candidate-state :native-inherited-dirty-node-ids))) + (setq overrides + (plist-put overrides :native-local-dirty-entries + (plist-get candidate-state + :native-local-dirty-entries))) (setq overrides (plist-put overrides :native-topology-stable-p @@ -10963,9 +10967,10 @@ fresh by the mixed projection." ((eq projection-kind 'native-frame) (append (ebox-incremental--viewport-state-overrides - candidate-state t) + candidate-state t) (list :native-topology-stable-p t :native-touched-node-ids nil + :native-local-dirty-entries nil :native-removed-node-ids nil))) ((memq projection-kind '(viewport-reflow viewport-reflow-mixed-scroll)) diff --git a/ebox-native-commit.el b/ebox-native-commit.el index 5d15ff7..92dd302 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -339,6 +339,7 @@ new session with the same bounded configuration used by retained frames." (progn (plist-put candidate-state :native-topology-stable-p t) (plist-put candidate-state :native-touched-node-ids nil) + (plist-put candidate-state :native-local-dirty-entries nil) (plist-put candidate-state :native-removed-node-ids nil) t))) @@ -375,6 +376,8 @@ new session with the same bounded configuration used by retained frames." old-state candidate-state)) (plist-put candidate-state :native-topology-stable-p stable) (plist-put candidate-state :native-touched-node-ids layout-touched) + (plist-put candidate-state :native-local-dirty-entries + (copy-tree (plist-get prepared :dirty-set))) (plist-put candidate-state :native-inherited-dirty-node-ids (ebox-native-commit-inherited-dirty-node-ids prepared)) (when context-axes-stable @@ -612,6 +615,7 @@ publication transaction can still roll back." (1+ (or (plist-get state :runtime-revision) 0)))) (plist-put state :native-frame-kind (if (plist-get frame :native-patch) 'patch 'full)) + (cl-remf state :native-local-dirty-entries) frame))) (defconst ebox-native-commit--failed-render-state-keys @@ -632,6 +636,7 @@ publication transaction can still roll back." :native-reuse-mount-projection-p :native-reuse-ownership-p :native-inherited-dirty-node-ids + :native-local-dirty-entries :native-topology-stable-p :native-touched-node-ids :native-removed-node-ids) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 43c67dc..16559a4 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -95,7 +95,7 @@ (defvar ebox--flex-content-min-width-table) -(defconst ebox-native-reflow-abi-version "11:7:12" +(defconst ebox-native-reflow-abi-version "12:7:12" "Version tuple shared by the native module, layout IR, and render tape.") (defconst ebox-native-reflow--minimum-rust-version "1.82.0" @@ -222,7 +222,10 @@ module while loading the package." styles layout-package layout-fragment-cache + layout-fragment-index layout-fragment-revision + layout-style-index + layout-property-template-index readiness-process readiness-preparation released-p) @@ -286,9 +289,74 @@ module while loading the package." (defvar ebox-native-reflow--compile-styles nil "Dynamically bound ordered style registry for one Layout IR compile.") +(defvar ebox-native-reflow--compile-style-base-count 0 + "Number of retained styles preceding the current append batch.") + +(defvar ebox-native-reflow--compile-style-index nil + "Persistent retained style-to-id index for incremental compilation.") + (defvar ebox-native-reflow--compile-property-templates nil "Dynamically bound ordered opaque text-property registry.") +(defvar ebox-native-reflow--compile-property-template-base-count 0 + "Number of retained property templates preceding current additions.") + +(defvar ebox-native-reflow--compile-property-template-index nil + "Persistent retained property-template-to-id index.") + +(defvar ebox-native-reflow--compile-retained-registry-p nil + "Non-nil while style and property registries append incrementally.") + +(defconst ebox-native-reflow--persistent-index-depth 16 + "Fixed number of four-bit branches in a retained native index path.") + +(defun ebox-native-reflow--persistent-index-code (key) + "Return a stable nonnegative retained-index code for KEY." + (if (and (integerp key) (>= key 0)) + key + (logand (sxhash-equal key) #xffffffffffffffff))) + +(defun ebox-native-reflow--persistent-index-get (root key &optional missing) + "Return KEY's value from persistent ROOT, or MISSING." + (let ((node root) + (code (ebox-native-reflow--persistent-index-code key)) + (depth 0)) + (while (and node (< depth ebox-native-reflow--persistent-index-depth)) + (setq node (and (vectorp node) + (aref node (logand (ash code (* -4 depth)) 15))) + depth (1+ depth))) + (if (= depth ebox-native-reflow--persistent-index-depth) + (let ((pair (cl-assoc key node :test #'equal))) + (if pair (cdr pair) missing)) + missing))) + +(defun ebox-native-reflow--persistent-index-put (root key value &optional depth) + "Return persistent ROOT updated to map KEY to VALUE. +Only the fixed-depth path for KEY is copied. DEPTH is for recursion." + (let ((depth (or depth 0))) + (if (= depth ebox-native-reflow--persistent-index-depth) + (cons (cons key value) + (cl-remove key root :key #'car :test #'equal)) + (let* ((code (ebox-native-reflow--persistent-index-code key)) + (branch (logand (ash code (* -4 depth)) 15)) + (copy (if (vectorp root) + (copy-sequence root) + (make-vector 16 nil)))) + (aset copy branch + (ebox-native-reflow--persistent-index-put + (aref copy branch) key value (1+ depth))) + copy)))) + +(defun ebox-native-reflow--persistent-index-from-sequence (sequence) + "Return an immutable persistent index for ordered SEQUENCE values." + (let (root) + (cl-loop for value across (vconcat sequence) + for id from 0 + do (setq root + (ebox-native-reflow--persistent-index-put + root value id))) + root)) + (defvar ebox-native-reflow--compile-property-template-ids nil "Dynamically bound opaque property-template lookup table.") @@ -1338,14 +1406,20 @@ copied separately; no pending frame or registered Rust document is inherited." (condition-case err (progn (setf (ebox-native-reflow-session-styles fork) - (copy-sequence (ebox-native-reflow-session-styles session)) + (ebox-native-reflow-session-styles session) (ebox-native-reflow-session-layout-package fork) (ebox-native-reflow-session-layout-package session) (ebox-native-reflow-session-layout-fragment-cache fork) - (copy-hash-table - (ebox-native-reflow-session-layout-fragment-cache session)) + (ebox-native-reflow-session-layout-fragment-cache session) + (ebox-native-reflow-session-layout-fragment-index fork) + (ebox-native-reflow-session-layout-fragment-index session) (ebox-native-reflow-session-layout-fragment-revision fork) - (ebox-native-reflow-session-layout-fragment-revision session)) + (ebox-native-reflow-session-layout-fragment-revision session) + (ebox-native-reflow-session-layout-style-index fork) + (ebox-native-reflow-session-layout-style-index session) + (ebox-native-reflow-session-layout-property-template-index fork) + (ebox-native-reflow-session-layout-property-template-index + session)) fork) (error (ebox-native-reflow-release-session fork) @@ -1409,7 +1483,11 @@ copied separately; no pending frame or registered Rust document is inherited." :properties owned-properties)) (missing (make-symbol "missing-property-template")) (existing - (if (hash-table-p + (if ebox-native-reflow--compile-retained-registry-p + (ebox-native-reflow--persistent-index-get + ebox-native-reflow--compile-property-template-index + entry missing) + (if (hash-table-p ebox-native-reflow--compile-property-template-ids) (gethash entry ebox-native-reflow--compile-property-template-ids @@ -1417,13 +1495,19 @@ copied separately; no pending frame or registered Rust document is inherited." (or (cl-position entry ebox-native-reflow--compile-property-templates :test #'equal) - missing)))) + missing))))) (if (not (eq existing missing)) existing - (let ((id (length ebox-native-reflow--compile-property-templates))) + (let ((id (+ ebox-native-reflow--compile-property-template-base-count + (length ebox-native-reflow--compile-property-templates)))) (setq ebox-native-reflow--compile-property-templates (append ebox-native-reflow--compile-property-templates (list entry))) + (when ebox-native-reflow--compile-retained-registry-p + (setq ebox-native-reflow--compile-property-template-index + (ebox-native-reflow--persistent-index-put + ebox-native-reflow--compile-property-template-index + entry id))) (when (hash-table-p ebox-native-reflow--compile-property-template-ids) (puthash @@ -1592,13 +1676,27 @@ When FORMATTED-P is non-nil, preserve exact display spaces produced by KP." (defun ebox-native-reflow--register-style (mode face) "Return the stable numeric id for a MODE and FACE operation." (let* ((entry (list :mode mode :face face)) - (existing (cl-position entry ebox-native-reflow--compile-styles - :test #'equal))) - (or existing - (prog1 (length ebox-native-reflow--compile-styles) + (missing (make-symbol "missing-native-style")) + (existing + (if ebox-native-reflow--compile-retained-registry-p + (ebox-native-reflow--persistent-index-get + ebox-native-reflow--compile-style-index entry missing) + (or (cl-position entry ebox-native-reflow--compile-styles + :test #'equal) + missing)))) + (if (not (eq existing missing)) + existing + (prog1 (+ ebox-native-reflow--compile-style-base-count + (length ebox-native-reflow--compile-styles)) (setq ebox-native-reflow--compile-styles (append ebox-native-reflow--compile-styles - (list entry))))))) + (list entry))) + (when ebox-native-reflow--compile-retained-registry-p + (setq ebox-native-reflow--compile-style-index + (ebox-native-reflow--persistent-index-put + ebox-native-reflow--compile-style-index entry + (+ ebox-native-reflow--compile-style-base-count + (1- (length ebox-native-reflow--compile-styles)))))))))) (defun ebox-native-reflow--compile-add-face-style (face) "Register additive FACE and return its id, or nil." @@ -2286,6 +2384,16 @@ Otherwise return CANDIDATE with the next document revision." (plist-put replacement :document-revision (1+ (or (plist-get old :document-revision) 0))))))) +(defun ebox-native-reflow--confirmed-layout-package (package) + "Return PACKAGE without one-shot retained input transport fields." + (if (plist-get package :document-delta) + (let ((confirmed (copy-sequence package))) + (dolist (key '(:document-delta :native-fragment-index + :native-fragment-revision :native-style-index)) + (cl-remf confirmed key)) + confirmed) + package)) + (defun ebox-native-reflow--buffer-display-signature (buffer) "Return BUFFER's live canonical Surface display capability." (with-current-buffer buffer @@ -2491,9 +2599,9 @@ per call, and no call recursively visits the captured Ebox tree." (gethash child flex-content-min-widths))) (ebox-native-reflow--retained-layout-children node)))))) -(defun ebox-native-reflow--compile-retained-layout-package +(defun ebox-native-reflow--compile-retained-layout-package-full (session state node) - "Compile NODE incrementally into retained native SESSION from STATE." + "Compile a complete retained package for NODE in SESSION from STATE." (let ((postorder (plist-get state :native-node-postorder))) (unless (and (vectorp postorder) (> (length postorder) 0)) (setq postorder @@ -2632,7 +2740,9 @@ per call, and no call recursively visits the captured Ebox tree." (setq fragment (ebox-native-reflow--compile-scene-node current fragments flex-content-min-widths) + fragment (plist-put fragment :node-id node-id) revision (1+ revision) + fragment (plist-put fragment :node-revision revision) entry (list :signature (copy-tree signature) :fragment fragment :revision revision))) @@ -2658,10 +2768,354 @@ per call, and no call recursively visits the captured Ebox tree." candidate))) (setf (ebox-native-reflow-session-layout-fragment-cache session) new-cache + (ebox-native-reflow-session-layout-fragment-index session) + (let (index) + (maphash + (lambda (node-id entry) + (setq index + (ebox-native-reflow--persistent-index-put + index node-id entry))) + new-cache) + index) (ebox-native-reflow-session-layout-fragment-revision session) - revision) + revision + (ebox-native-reflow-session-layout-style-index session) + (ebox-native-reflow--persistent-index-from-sequence + (plist-get package :styles)) + (ebox-native-reflow-session-layout-property-template-index + session) + (ebox-native-reflow--persistent-index-from-sequence + (plist-get package :property-templates))) package))))) +(defconst ebox-native-reflow--delta-edge-fields + '(:type :child :children :items :node-id :node-revision) + "Fragment fields that a stable local node delta cannot replace.") + +(defconst ebox-native-reflow--delta-flex-edge-properties + '(:order :flex-grow :flex-shrink :flex-basis :align-self + :flex-direction :cross-align) + "Changed properties that alter retained Flex item metadata in N1.") + +(defun ebox-native-reflow--delta-flex-edge-change-p (state) + "Return non-nil when STATE changes unsupported Flex edge metadata." + (cl-some + (lambda (dirty) + (let* ((keys (plist-get dirty :changed-keys)) + (parents (plist-get state :parent-table)) + (nodes (plist-get state :node-table)) + (dirty-id (plist-get dirty :node-id)) + (dirty-node (and nodes (gethash dirty-id nodes))) + (raw-parent-id (and parents (gethash dirty-id parents))) + (raw-parent (and nodes (gethash raw-parent-id nodes))) + (owner-id + (if (and (eq (plist-get dirty-node :ebox-kind) 'text) + (eq (plist-get raw-parent :ebox-kind) 'box) + (when-let* ((layout + (plist-get raw-parent + :ebox-layout-config))) + (eq (ebox-layout-config-kind layout) 'normal))) + raw-parent-id + dirty-id)) + (edge-parent-id (and parents (gethash owner-id parents))) + (edge-parent (and nodes (gethash edge-parent-id nodes))) + (config + (and edge-parent + (plist-get edge-parent :ebox-layout-config)))) + (or + (cl-some (lambda (key) + (memq key ebox-native-reflow--delta-flex-edge-properties)) + keys) + ;; A child-local edit can change content-min-width in a Flex item or + ;; derived align-self in typed axis lowering. N1 has no edge patch, + ;; so retain correctness through full input until exact edge facts are + ;; independently addressable. + (or (eq (plist-get edge-parent :ebox-type) 'flex) + (and config + (let ((kind (ebox-layout-config-kind config)) + (props (ebox-layout-config-props config))) + (or (eq kind 'flex) + (and (memq kind '(row column)) + (not (and (equal (plist-get props :item-gap) 0) + (eq (plist-get props :cross-align) + 'stretch))))))))))) + (plist-get state :native-local-dirty-entries))) + +(defun ebox-native-reflow--fragment-local (fragment) + "Return FRAGMENT's scalar and local-content fields." + (let ((tail fragment) local) + (while tail + (let ((key (pop tail)) + (value (pop tail))) + (unless (memq key ebox-native-reflow--delta-edge-fields) + (setq local (nconc local (list key value)))))) + local)) + +(defun ebox-native-reflow--local-difference (old new) + "Return NEW local fields whose values differ from OLD." + (let ((tail new) difference) + (while tail + (let ((key (pop tail)) + (value (pop tail))) + (unless (equal value (plist-get old key)) + (setq difference (nconc difference (list key value)))))) + difference)) + +(defun ebox-native-reflow--apply-local-difference (fragment difference) + "Return FRAGMENT with scalar fields from DIFFERENCE replaced." + (let ((copy (copy-sequence fragment)) + (tail difference)) + (while tail + (setq copy (plist-put copy (pop tail) (pop tail)))) + copy)) + +(defun ebox-native-reflow--delta-owner-id (state node-id index) + "Return addressable owner for NODE-ID in STATE and retained INDEX." + (let ((parents (plist-get state :parent-table)) + (nodes (plist-get state :node-table)) + (current node-id)) + (when-let* ((source (and nodes (gethash current nodes))) + (parent-id (and parents (gethash current parents))) + (parent (and nodes (gethash parent-id nodes)))) + (when (and (eq (plist-get source :ebox-kind) 'text) + (eq (plist-get parent :ebox-kind) 'box) + (eq (ebox-layout-config-kind + (plist-get parent :ebox-layout-config)) + 'normal)) + (setq current parent-id))) + (while (and current + (null (ebox-native-reflow--persistent-index-get + index current))) + (setq current (and parents (gethash current parents)))) + current)) + +(defun ebox-native-reflow--compile-delta-slots (node old-fragment) + "Compile NODE local slots while retaining OLD-FRAGMENT edges." + (pcase (plist-get node :ebox-type) + ('box + (if (eq (plist-get node :ebox-kind) 'text) + (vector (ebox-native-reflow--compile-text-node node)) + (let* ((config (and (eq (plist-get node :ebox-kind) 'box) + (plist-get node :ebox-layout-config))) + (kind (and config (ebox-layout-config-kind config))) + (old-child (plist-get old-fragment :child)) + (fused-p (and (eq kind 'normal) + (let ((child (car (ebox-tree-node-children node)))) + (and child + (eq (plist-get child :ebox-kind) 'text))))) + (inner + (pcase kind + ((or 'row 'column) + (ebox-native-reflow--compile-typed-axis-children + kind config nil)) + ('flex + (ebox-native-reflow--compile-flex-inner + (copy-sequence (ebox-layout-config-props config)) nil)) + (_ nil))) + (child (cond (fused-p + (ebox-native-reflow--compile-text-node + (car (ebox-tree-node-children node)))) + (inner inner) + (t old-child))) + (outer (ebox-native-reflow--compile-box node child nil t))) + (if inner (vector outer inner) (vector outer))))) + ('flex + (let* ((wrapper (plist-get node :box)) + (props (ebox--flex-container-content-props + (plist-get node :props) (plist-get node :raw-props) + wrapper))) + (when wrapper + (plist-put props :width nil) + (when (plist-member (plist-get node :raw-props) :height) + (plist-put props :height 'viewport-height))) + (let ((inner (ebox-native-reflow--compile-flex-inner props nil))) + (if wrapper + (vector (ebox-native-reflow--compile-box wrapper inner t) inner) + (vector inner))))) + (_ (vector old-fragment)))) + +(defun ebox-native-reflow--delta-direct-owner-ids (state index) + "Return owners for STATE's direct dirty entries found in INDEX." + (let ((seen (make-hash-table :test 'equal)) result) + (dolist (dirty (plist-get state :native-local-dirty-entries)) + (when-let* ((owner + (ebox-native-reflow--delta-owner-id + state (plist-get dirty :node-id) index))) + (unless (gethash owner seen) + (puthash owner t seen) + (push owner result)))) + (nreverse result))) + +(defun ebox-native-reflow--compile-retained-layout-delta + (session state node) + "Return SESSION's local delta for NODE under STATE, or nil for fallback." + (let* ((old-package (ebox-native-reflow-session-layout-package session)) + (old-index (ebox-native-reflow-session-layout-fragment-index session)) + (node-table (plist-get state :node-table)) + (touched (plist-get state :native-touched-node-ids))) + (when (and old-package old-index (hash-table-p node-table) + (plist-get state :native-topology-stable-p) + (plist-member state :native-local-dirty-entries) + (not (ebox-native-reflow--delta-flex-edge-change-p state)) + ;; Descendant inherited-style expansion needs its own bounded + ;; affected-node index. Until then it is an explicit full + ;; input fallback rather than a partial semantic update. + (null (plist-get state :native-inherited-dirty-node-ids))) + (if (null touched) + old-package + (let* ((style-base (length (plist-get old-package :styles))) + (template-base + (length (plist-get old-package :property-templates))) + (ebox-native-reflow--compile-styles nil) + (ebox-native-reflow--compile-style-base-count style-base) + (ebox-native-reflow--compile-style-index + (ebox-native-reflow-session-layout-style-index session)) + (ebox-native-reflow--compile-property-templates nil) + (ebox-native-reflow--compile-property-template-base-count + template-base) + (ebox-native-reflow--compile-property-template-index + (ebox-native-reflow-session-layout-property-template-index + session)) + (ebox-native-reflow--compile-retained-registry-p t) + (ebox-native-reflow--compile-display-signature + (ebox--current-display-signature)) + (ebox-native-reflow--compile-root-node node) + (ebox--render-string-pixel-width-cache + (make-hash-table :test 'equal)) + (ebox--render-string-max-pixel-width-cache + (make-hash-table :test 'eq)) + (direct (ebox-native-reflow--delta-direct-owner-ids + state old-index)) + (direct-set (make-hash-table :test 'equal)) + (entry-ids nil) + (seen (make-hash-table :test 'equal)) + (new-index old-index) + (revision + (or (ebox-native-reflow-session-layout-fragment-revision + session) 0)) + entries unsupported) + (dolist (id direct) (puthash id t direct-set)) + (dolist (id touched) + (when-let* ((owner + (ebox-native-reflow--delta-owner-id state id old-index))) + (unless (gethash owner seen) + (puthash owner t seen) + (push owner entry-ids)))) + (dolist (owner-id (nreverse entry-ids)) + (let* ((cached (ebox-native-reflow--persistent-index-get + old-index owner-id)) + (old-fragment (plist-get cached :fragment)) + (source (gethash owner-id node-table)) + (expected (plist-get cached :revision)) + patches new-fragment) + (unless (and cached source (integerp expected)) + (setq unsupported t)) + (when (and (not unsupported) (gethash owner-id direct-set)) + (let ((slots + (ebox-native-reflow--compile-delta-slots + source old-fragment))) + (unless (equal (plist-get (aref slots 0) :type) + (plist-get old-fragment :type)) + (setq unsupported t)) + (unless unsupported + (let ((local + (ebox-native-reflow--local-difference + (ebox-native-reflow--fragment-local old-fragment) + (ebox-native-reflow--fragment-local + (aref slots 0))))) + (when local + (push (list :slot 0 :local local) patches) + (setq new-fragment + (ebox-native-reflow--apply-local-difference + old-fragment local)))) + (when (> (length slots) 1) + (let ((old-inner (plist-get old-fragment :child)) + (new-inner (aref slots 1))) + (unless (equal (plist-get old-inner :type) + (plist-get new-inner :type)) + (setq unsupported t)) + (unless unsupported + (let ((local + (ebox-native-reflow--local-difference + (ebox-native-reflow--fragment-local old-inner) + (ebox-native-reflow--fragment-local new-inner)))) + (when local + (push (list :slot 1 :local local) patches) + (let ((outer (or new-fragment + (copy-sequence old-fragment)))) + (plist-put + outer :child + (ebox-native-reflow--apply-local-difference + old-inner local)) + (setq new-fragment outer)))))))))) + (unless unsupported + (setq revision (1+ revision)) + (unless new-fragment + (setq new-fragment (copy-sequence old-fragment))) + (plist-put new-fragment :node-id owner-id) + (plist-put new-fragment :node-revision revision) + (let* ((entry + (append + (list :node-id owner-id + :expected-revision expected + :target-revision revision) + (when patches + (list :slot-patches (vconcat (nreverse patches)))))) + (new-cache-entry + (list :signature nil + :fragment (or new-fragment old-fragment) + :revision revision))) + (push entry entries) + (setq new-index + (ebox-native-reflow--persistent-index-put + new-index owner-id new-cache-entry)))))) + (when (and (not unsupported) + ;; N1 can authenticate retained templates, but cannot + ;; append their Emacs-owned values to Rust atomically. + (= template-base + (+ template-base + (length ebox-native-reflow--compile-property-templates)))) + (let* ((styles-append ebox-native-reflow--compile-styles) + (styles + (if styles-append + ;; Materializing the global style output is explicitly + ;; outside retained input work; the common no-append + ;; path preserves the old vector by identity. + (vconcat (plist-get old-package :styles) styles-append) + (plist-get old-package :styles))) + (base (or (plist-get old-package :document-revision) 0)) + (package (copy-sequence old-package))) + (plist-put package :document-revision (1+ base)) + (plist-put package :styles styles) + (plist-put + package :document-delta + (list :style-base-count style-base + :styles-append + (vconcat + (mapcar + (lambda (descriptor) + (list :mode (symbol-name (plist-get descriptor :mode)) + :face + (list :lisp + (prin1-to-string + (plist-get descriptor :face))))) + styles-append)) + :property-template-base-count template-base + :property-template-target-count template-base + :entries (vconcat (nreverse entries)))) + (plist-put package :native-fragment-index new-index) + (plist-put package :native-fragment-revision revision) + (plist-put package :native-style-index + ebox-native-reflow--compile-style-index) + package))))))) + +(defun ebox-native-reflow--compile-retained-layout-package + (session state node) + "Compile NODE for SESSION using a delta when STATE proves it supported." + (or (ebox-native-reflow--compile-retained-layout-delta session state node) + (ebox-native-reflow--compile-retained-layout-package-full + session state node))) + (defun ebox-native-reflow-compile-layout-ir (node) "Compile NODE to one complete versioned native layout document." (plist-get (ebox-native-reflow--compile-layout-package node) :document)) @@ -2682,9 +3136,10 @@ per call, and no call recursively visits the captured Ebox tree." (defun ebox-native-reflow--layout-control-json (document frames &optional document-base-revision - document-target-revision) + document-target-revision document-delta) "Return strict control JSON for optional layout DOCUMENT and FRAMES. -DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input." +DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input. +DOCUMENT-DELTA is the stable-topology local replacement batch." (unless (and (listp frames) frames) (error "Native reflow requires at least one layout frame")) (let ((control @@ -2792,6 +3247,8 @@ DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input." document-target-revision))) (when document (setq control (plist-put control :document document))) + (when document-delta + (setq control (plist-put control :document-delta document-delta))) (ebox-native-reflow--serialize-layout-control control))) (defun ebox-native-reflow-submit-layout @@ -2807,9 +3264,12 @@ LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." node (ebox-native-reflow-session-styles session) (plist-get old-package :property-templates)))) + (document-delta (plist-get candidate-package :document-delta)) (package - (ebox-native-reflow--reuse-exact-layout-package - old-package candidate-package)) + (if document-delta + candidate-package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package))) (register-layout-p (not (eq package old-package))) @@ -2821,12 +3281,14 @@ LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." (ebox-native-reflow--live-handle session) generation (ebox-native-reflow--layout-control-json - (and register-layout-p (plist-get package :document)) frames - base-revision target-revision)))) + (and register-layout-p (not document-delta) + (plist-get package :document)) + frames base-revision target-revision document-delta)))) (setf (ebox-native-reflow-session-generation session) generation) (setf (ebox-native-reflow-session-styles session) (plist-get package :styles)) - (setf (ebox-native-reflow-session-layout-package session) package) + (setf (ebox-native-reflow-session-layout-package session) + (ebox-native-reflow--confirmed-layout-package package)) accepted)) (defun ebox-native-reflow--tape-cursor (payload &optional position) @@ -3968,13 +4430,16 @@ root effect metadata required by a thin host commit." (plist-get (ebox-native-reflow-session-layout-package session) :property-templates))))) + (document-delta (plist-get candidate-package :document-delta)) (package - (ebox-native-reflow--reuse-exact-layout-package - old-package candidate-package)) + (if document-delta + candidate-package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package))) (generation (1+ (ebox-native-reflow-session-generation session))) (control-frame (copy-sequence frame)) (native-key (or (plist-get control-frame :key) 1)) - (reuse-document-p (eq package old-package)) + (reuse-document-p (or document-delta (eq package old-package))) (base-revision (or (plist-get old-package :document-revision) 0)) (target-revision (or (plist-get package :document-revision) (1+ base-revision)))) @@ -3986,12 +4451,22 @@ root effect metadata required by a thin host commit." generation (ebox-native-reflow--layout-control-json (unless reuse-document-p (plist-get package :document)) - (list control-frame) base-revision target-revision)) + (list control-frame) base-revision target-revision + document-delta)) package control-frame generation))) (setf (ebox-native-reflow-session-generation session) generation - (ebox-native-reflow-session-layout-package session) package + (ebox-native-reflow-session-layout-package session) + (ebox-native-reflow--confirmed-layout-package package) (ebox-native-reflow-session-styles session) (plist-get package :styles)) + (when document-delta + (setf (ebox-native-reflow-session-layout-fragment-cache session) nil + (ebox-native-reflow-session-layout-fragment-index session) + (plist-get package :native-fragment-index) + (ebox-native-reflow-session-layout-fragment-revision session) + (plist-get package :native-fragment-revision) + (ebox-native-reflow-session-layout-style-index session) + (plist-get package :native-style-index))) result))) (defun ebox-native-reflow-render-proof-sync diff --git a/ebox-state-contract.el b/ebox-state-contract.el index d592c66..bfa2367 100644 --- a/ebox-state-contract.el +++ b/ebox-state-contract.el @@ -278,6 +278,12 @@ rather than relying on an implicit default.") (:symbol ebox-native-reflow--compile-property-template-ids :reason dynamically-bound-compile-local-scratch :evidence let-bound-per-compile-and-never-committed) + (:symbol ebox-native-reflow--compile-property-template-index + :reason dynamically-bound-compile-local-scratch + :evidence let-bound-per-compile-and-published-only-through-session-root) + (:symbol ebox-native-reflow--compile-style-index + :reason dynamically-bound-compile-local-scratch + :evidence let-bound-per-compile-and-published-only-through-session-root) (:symbol ebox--render-source-generations :reason dynamically-bound-render-proof-input :evidence let-bound-to-candidate-and-prior-generations-for-one-plan) diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c index c4ac048..9050ef8 100644 --- a/native/c/ebox_module.c +++ b/native/c/ebox_module.c @@ -164,7 +164,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args, (void) nargs; (void) args; (void) data; - static const char version[] = "11:7:12"; + static const char version[] = "12:7:12"; return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1)); } diff --git a/native/src/layout.rs b/native/src/layout.rs index 6e386bf..2f40df7 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -1,16 +1,51 @@ use etaf_core::{diff_commit_batch, CommitBatch, SpanEdit}; -use serde::Deserialize; -use std::collections::BTreeMap; - -#[cfg(test)] +use serde::{Deserialize, Deserializer}; +use serde_json::{Map as JsonMap, Value as JsonValue}; use std::cell::Cell; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::sync::Arc; + +fn deserialize_arc_vec<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Vec::::deserialize(deserializer).map(Arc::new) +} + +fn deserialize_optional_arc<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.map(Arc::new)) +} + +fn deserialize_arc<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Arc::new) +} -#[cfg(test)] thread_local! { + static RESOLVER_LOOKUP_COUNT: Cell = const { Cell::new(0) }; + #[cfg(test)] static TEST_RENDER_NODE_COUNT: Cell> = const { Cell::new(None) }; + #[cfg(test)] static TEST_DISABLE_WINDOW_RENDER: Cell = const { Cell::new(false) }; } +pub(crate) fn reset_resolver_lookups() { + RESOLVER_LOOKUP_COUNT.with(|count| count.set(0)); +} + +pub(crate) fn resolver_lookups() -> u64 { + RESOLVER_LOOKUP_COUNT.with(Cell::get) +} + const LAYOUT_VERSION: u32 = 2; pub const TAPE_VERSION: u16 = 12; pub const TAPE_HEADER_LEN: usize = 112; @@ -47,7 +82,7 @@ const METADATA_SCROLL_CONTENT: u8 = 16; const METADATA_SCROLL_OWNER: u8 = 17; const METADATA_SCROLL_WINDOW: u8 = 18; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LayoutDocument { version: u32, @@ -60,6 +95,962 @@ pub struct LayoutDocument { root: LayoutNode, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct DocumentDelta { + pub(crate) style_base_count: u32, + #[serde(default)] + pub(crate) styles_append: Vec, + pub(crate) property_template_base_count: u32, + pub(crate) property_template_target_count: u32, + pub(crate) entries: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct DocumentDeltaEntry { + pub(crate) node_id: u64, + pub(crate) expected_revision: u64, + pub(crate) target_revision: u64, + #[serde(default)] + slot_patches: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct LocalSlotPatch { + slot: u8, + local: JsonMap, +} + +#[derive(Debug)] +struct RetainedEntry { + revision: u64, + node: Arc, + slot_work: [Arc; 2], +} + +#[derive(Debug, Clone, Default)] +struct LocalWorkSummary(BTreeMap<&'static str, usize>); + +#[derive(Debug)] +struct RadixNode { + children: [Option>; 16], + value: Option>, +} + +impl Default for RadixNode { + fn default() -> Self { + Self { + children: std::array::from_fn(|_| None), + value: None, + } + } +} + +fn radix_lookup(root: &Arc, key: u64) -> Option<&Arc> { + let mut current = root.as_ref(); + for level in 0..16 { + let shift = 60 - level * 4; + current = current.children[((key >> shift) & 0xf) as usize].as_deref()?; + } + current.value.as_ref() +} + +fn radix_insert( + root: &Arc, + key: u64, + value: Arc, +) -> (Arc, u64) { + fn insert_at( + node: &Arc, + key: u64, + level: usize, + value: &Arc, + copied: &mut u64, + ) -> Arc { + *copied += 1; + let mut children = node.children.clone(); + let mut stored = node.value.clone(); + if level == 16 { + stored = Some(Arc::clone(value)); + } else { + let shift = 60 - level * 4; + let index = ((key >> shift) & 0xf) as usize; + let child = children[index] + .clone() + .unwrap_or_else(|| Arc::new(RadixNode::default())); + children[index] = Some(insert_at(&child, key, level + 1, value, copied)); + } + Arc::new(RadixNode { + children, + value: stored, + }) + } + + let mut copied = 0; + (insert_at(root, key, 0, &value, &mut copied), copied) +} + +#[derive(Debug)] +pub(crate) struct RetainedDocument { + root_id: u64, + entries: Arc, + styles: Arc, + style_count: u32, + property_template_count: u32, + work_units: usize, +} + +#[derive(Debug)] +struct StyleRadixNode { + children: [Option>; 16], + value: Option>, +} + +impl Default for StyleRadixNode { + fn default() -> Self { + Self { + children: std::array::from_fn(|_| None), + value: None, + } + } +} + +fn style_lookup(root: &Arc, key: u64) -> Option<&Arc> { + let mut current = root.as_ref(); + for level in 0..16 { + let shift = 60 - level * 4; + current = current.children[((key >> shift) & 0xf) as usize].as_deref()?; + } + current.value.as_ref() +} + +fn style_insert( + root: &Arc, + key: u64, + value: Arc, +) -> (Arc, u64) { + fn insert_at( + node: &Arc, + key: u64, + level: usize, + value: &Arc, + copied: &mut u64, + ) -> Arc { + *copied += 1; + let mut children = node.children.clone(); + let mut stored = node.value.clone(); + if level == 16 { + stored = Some(Arc::clone(value)); + } else { + let shift = 60 - level * 4; + let index = ((key >> shift) & 0xf) as usize; + let child = children[index] + .clone() + .unwrap_or_else(|| Arc::new(StyleRadixNode::default())); + children[index] = Some(insert_at(&child, key, level + 1, value, copied)); + } + Arc::new(StyleRadixNode { + children, + value: stored, + }) + } + + let mut copied = 0; + (insert_at(root, key, 0, &value, &mut copied), copied) +} + +impl LayoutNode { + fn node_id(&self) -> Option { + match self { + Self::Box { node_id, .. } + | Self::Text { node_id, .. } + | Self::Row { node_id, .. } + | Self::Column { node_id, .. } + | Self::Flex { node_id, .. } => *node_id, + Self::NodeRef { node_id } => Some(*node_id), + } + } + + fn node_revision(&self) -> Option { + match self { + Self::Box { node_revision, .. } + | Self::Text { node_revision, .. } + | Self::Row { node_revision, .. } + | Self::Column { node_revision, .. } + | Self::Flex { node_revision, .. } => *node_revision, + Self::NodeRef { .. } => None, + } + } +} + +fn visit_layout_children(node: &LayoutNode, mut visit: impl FnMut(&LayoutNode)) { + match node { + LayoutNode::Box { child, .. } => { + if let Some(child) = child.as_deref() { + visit(child); + } + } + LayoutNode::Row { children, .. } => { + for child in children.iter() { + visit(child); + } + } + LayoutNode::Column { children, .. } => { + for child in children.iter() { + visit(child); + } + } + LayoutNode::Flex { items, .. } => { + for item in items.iter() { + visit(&item.node); + } + } + LayoutNode::Text { .. } | LayoutNode::NodeRef { .. } => {} + } +} + +impl RetainedDocument { + pub(crate) fn bootstrap(document: LayoutDocument) -> Result<(Arc, u64), String> { + let (node_count, work_units) = document.validate_metrics()?; + fn install_owner( + mut node: LayoutNode, + entries: &mut Arc, + seen: &mut HashSet, + count: &mut u64, + ) -> Result { + fn localize( + node: &mut LayoutNode, + owner_id: u64, + entries: &mut Arc, + seen: &mut HashSet, + count: &mut u64, + ) -> Result<(), String> { + fn localize_child( + child: LayoutNode, + owner_id: u64, + entries: &mut Arc, + seen: &mut HashSet, + count: &mut u64, + ) -> Result { + if child.node_id().is_some() { + let installed = install_owner(child, entries, seen, count)?; + return Ok(LayoutNode::NodeRef { node_id: installed }); + } + let mut child = child; + localize(&mut child, owner_id, entries, seen, count)?; + Ok(child) + } + + match node { + LayoutNode::Box { child, .. } => { + if let Some(old) = child.take() { + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + *child = Some(Arc::new(localize_child( + old, owner_id, entries, seen, count, + )?)); + } + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + let old = std::mem::take(children); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for child in old { + localized.push(localize_child(child, owner_id, entries, seen, count)?); + } + *children = Arc::new(localized); + } + LayoutNode::Flex { items, .. } => { + let old = std::mem::take(items); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for mut item in old { + item.node = localize_child(item.node, owner_id, entries, seen, count)?; + localized.push(item); + } + *items = Arc::new(localized); + } + LayoutNode::Text { .. } => {} + LayoutNode::NodeRef { .. } => { + return Err( + "Native retained bootstrap received a node reference".to_owned() + ); + } + } + Ok(()) + } + + let node_id = node.node_id().ok_or_else(|| { + "Native retained document owner is missing its node id".to_owned() + })?; + let revision = node.node_revision().ok_or_else(|| { + "Native retained document node is missing its revision".to_owned() + })?; + if node_id == 0 || !seen.insert(node_id) { + return Err("Native retained document has invalid duplicate node id".to_owned()); + } + localize(&mut node, node_id, entries, seen, count)?; + let slot_one = match &node { + LayoutNode::Box { + child: Some(child), .. + } if child.node_id().is_none() => local_work_summary(child)?, + _ => LocalWorkSummary::default(), + }; + let entry = Arc::new(RetainedEntry { + revision, + slot_work: [Arc::new(local_work_summary(&node)?), Arc::new(slot_one)], + node: Arc::new(node), + }); + let (updated, _) = radix_insert(entries, node_id, entry); + *entries = updated; + *count += 1; + Ok(node_id) + } + + let mut entries = Arc::new(RadixNode::default()); + let mut seen = HashSet::new(); + let mut owner_count = 0; + let root_id = install_owner(document.root, &mut entries, &mut seen, &mut owner_count)?; + debug_assert!(usize::try_from(owner_count).is_ok_and(|count| count <= node_count)); + let mut styles = Arc::new(StyleRadixNode::default()); + if document.styles.len() != document.style_count as usize { + return Err("Native retained style table count mismatch".to_owned()); + } + for (index, style) in document.styles.into_iter().enumerate() { + let (updated, _) = style_insert(&styles, index as u64, Arc::new(style)); + styles = updated; + } + Ok(( + Arc::new(Self { + root_id, + entries, + styles, + style_count: document.style_count, + property_template_count: document.property_template_count, + work_units, + }), + node_count as u64, + )) + } + + fn effective_node(&self, node_id: u64) -> Option<&LayoutNode> { + radix_lookup(&self.entries, node_id).map(|entry| entry.node.as_ref()) + } + + fn resolve<'a>(&'a self, fallback: &'a LayoutNode) -> Result<&'a LayoutNode, String> { + match fallback { + LayoutNode::NodeRef { node_id } => { + RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + self.effective_node(*node_id).ok_or_else(|| { + "Native retained document has an unknown node reference".to_owned() + }) + } + _ => Ok(fallback), + } + } + + pub(crate) fn styles(&self) -> Result, String> { + (0..self.style_count) + .map(|index| { + style_lookup(&self.styles, index as u64) + .map(|style| style.as_ref().clone()) + .ok_or_else(|| "Native retained style registry has a gap".to_owned()) + }) + .collect() + } + + pub(crate) fn apply_delta( + &self, + delta: DocumentDelta, + ) -> Result<(Arc, u64, u64), String> { + if delta.style_base_count != self.style_count + || delta.property_template_base_count != self.property_template_count + || delta.property_template_target_count < delta.property_template_base_count + || delta.property_template_target_count > MAX_PROPERTY_TEMPLATE_COUNT + { + return Err("Native retained delta registry base mismatch".to_owned()); + } + for style in &delta.styles_append { + style.face.validate()?; + } + let target_style_count = self + .style_count + .checked_add(delta.styles_append.len() as u32) + .ok_or_else(|| "Native retained style table count overflow".to_owned())?; + if target_style_count as usize > MAX_TAPE_PROPERTY_ENTRIES { + return Err("Native retained style table exceeds its entry limit".to_owned()); + } + let mut seen = HashSet::with_capacity(delta.entries.len()); + let mut prepared = Vec::with_capacity(delta.entries.len()); + let mut work_delta_total = 0_i128; + for update in delta.entries { + if !seen.insert(update.node_id) { + return Err("Native retained delta contains a duplicate node id".to_owned()); + } + let old = radix_lookup(&self.entries, update.node_id) + .ok_or_else(|| "Native retained delta names an unknown node id".to_owned())?; + if old.revision != update.expected_revision + || update.target_revision <= update.expected_revision + { + return Err("Native retained delta node revision mismatch".to_owned()); + } + let node = if update.slot_patches.is_empty() { + Arc::clone(&old.node) + } else { + let (patched, slot_work, work_delta) = patch_owner_slots( + &old.node, + &old.slot_work, + &update.slot_patches, + target_style_count, + delta.property_template_target_count, + )?; + work_delta_total = work_delta_total + .checked_add(work_delta as i128) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + prepared.push(( + update.node_id, + Arc::new(RetainedEntry { + revision: update.target_revision, + node: Arc::new(patched), + slot_work, + }), + )); + continue; + }; + prepared.push(( + update.node_id, + Arc::new(RetainedEntry { + revision: update.target_revision, + node, + slot_work: old.slot_work.clone(), + }), + )); + } + let work_units = if work_delta_total < 0 { + self.work_units + .checked_sub( + usize::try_from(-work_delta_total) + .map_err(|_| "Native retained work summary invariant failed".to_owned())?, + ) + .ok_or_else(|| "Native retained work summary invariant failed".to_owned())? + } else { + self.work_units + .checked_add( + usize::try_from(work_delta_total) + .map_err(|_| "Native layout work estimate overflowed".to_owned())?, + ) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())? + }; + if work_units > MAX_LAYOUT_WORK_UNITS { + return Err("Native layout exceeds the work-unit limit".to_owned()); + } + let parsed = prepared.len() as u64; + let mut entries = Arc::clone(&self.entries); + let mut copied = 0; + for (node_id, entry) in prepared { + let (next, count) = radix_insert(&entries, node_id, entry); + entries = next; + copied += count; + } + let mut styles = Arc::clone(&self.styles); + for (offset, style) in delta.styles_append.into_iter().enumerate() { + let style_id = self.style_count as u64 + offset as u64; + let (updated, count) = style_insert(&styles, style_id, Arc::new(style)); + styles = updated; + copied += count; + } + Ok(( + Arc::new(Self { + root_id: self.root_id, + entries, + styles, + style_count: target_style_count, + property_template_count: delta.property_template_target_count, + work_units, + }), + parsed, + copied, + )) + } +} + +fn patch_owner_slots( + base: &LayoutNode, + base_work: &[Arc; 2], + patches: &[LocalSlotPatch], + style_count: u32, + property_template_count: u32, +) -> Result<(LayoutNode, [Arc; 2], isize), String> { + let mut result = base.clone(); + let mut slot_work = base_work.clone(); + let mut work_delta = 0_isize; + let mut seen = [false; 2]; + for patch in patches { + let index = patch.slot as usize; + if index >= seen.len() || std::mem::replace(&mut seen[index], true) { + return Err("Native retained delta has an invalid duplicate slot".to_owned()); + } + if patch.slot == 0 { + let old_work = slot_work[0].changed_work(&patch.local); + result = patch_local_node(&result, &patch.local)?; + validate_changed_fields(&result, &patch.local, style_count, property_template_count)?; + let updated = slot_work[0].updated(&result, &patch.local)?; + let new_work = updated.changed_work(&patch.local); + work_delta += new_work as isize - old_work as isize; + slot_work[0] = Arc::new(updated); + } else { + let LayoutNode::Box { child, .. } = &mut result else { + return Err("Native retained delta slot one requires a box owner".to_owned()); + }; + let old_child = child + .as_deref() + .ok_or_else(|| "Native retained delta slot one is absent".to_owned())?; + if old_child.node_id().is_some() { + return Err("Native retained delta slot one must be anonymous".to_owned()); + } + let old_work = slot_work[1].changed_work(&patch.local); + let new_child = patch_local_node(old_child, &patch.local)?; + validate_changed_fields( + &new_child, + &patch.local, + style_count, + property_template_count, + )?; + let updated = slot_work[1].updated(&new_child, &patch.local)?; + let new_work = updated.changed_work(&patch.local); + work_delta += new_work as isize - old_work as isize; + slot_work[1] = Arc::new(updated); + *child = Some(Arc::new(new_child)); + } + } + Ok((result, slot_work, work_delta)) +} + +fn patch_local_node( + base: &LayoutNode, + fields: &JsonMap, +) -> Result { + fn parsed(value: &JsonValue) -> Result { + serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid native retained local field: {error}")) + } + let mut output = base.clone(); + for (name, value) in fields { + match &mut output { + LayoutNode::Box { + region_id, + content, + content_region_id, + content_typography_style, + content_foreground_style, + content_surface_template_id, + content_width_exact, + content_min_width, + width, + min_width, + max_width, + height, + min_height, + max_height, + box_sizing, + padding_left, + padding_right, + padding_top, + padding_bottom, + margin_left, + margin_right, + margin_top, + margin_bottom, + border_left, + border_right, + typography_style, + foreground_style, + background_style, + border_left_style, + border_right_style, + border_top_style, + border_bottom_style, + surface_template_id, + text_align, + vertical_align, + overflow, + wrap_mode, + scroll_offset, + .. + } => match name.as_str() { + "region-id" => *region_id = parsed(value)?, + "content" => *content = parsed::>(value)?.map(Arc::new), + "content-region-id" => *content_region_id = parsed(value)?, + "content-typography-style" => *content_typography_style = parsed(value)?, + "content-foreground-style" => *content_foreground_style = parsed(value)?, + "content-surface-template-id" => *content_surface_template_id = parsed(value)?, + "content-width-exact" => *content_width_exact = parsed(value)?, + "content-min-width" => *content_min_width = parsed(value)?, + "width" => *width = parsed(value)?, + "min-width" => *min_width = parsed(value)?, + "max-width" => *max_width = parsed(value)?, + "height" => *height = parsed(value)?, + "min-height" => *min_height = parsed(value)?, + "max-height" => *max_height = parsed(value)?, + "box-sizing" => *box_sizing = parsed(value)?, + "padding-left" => *padding_left = parsed(value)?, + "padding-right" => *padding_right = parsed(value)?, + "padding-top" => *padding_top = parsed(value)?, + "padding-bottom" => *padding_bottom = parsed(value)?, + "margin-left" => *margin_left = parsed(value)?, + "margin-right" => *margin_right = parsed(value)?, + "margin-top" => *margin_top = parsed(value)?, + "margin-bottom" => *margin_bottom = parsed(value)?, + "border-left" => *border_left = parsed(value)?, + "border-right" => *border_right = parsed(value)?, + "typography-style" => *typography_style = parsed(value)?, + "foreground-style" => *foreground_style = parsed(value)?, + "background-style" => *background_style = parsed(value)?, + "border-left-style" => *border_left_style = parsed(value)?, + "border-right-style" => *border_right_style = parsed(value)?, + "border-top-style" => *border_top_style = parsed(value)?, + "border-bottom-style" => *border_bottom_style = parsed(value)?, + "surface-template-id" => *surface_template_id = parsed(value)?, + "text-align" => *text_align = parsed(value)?, + "vertical-align" => *vertical_align = parsed(value)?, + "overflow" => *overflow = parsed(value)?, + "wrap-mode" => *wrap_mode = parsed(value)?, + "scroll-offset" => *scroll_offset = parsed(value)?, + _ => return Err(format!("Unsupported native retained box field {name}")), + }, + LayoutNode::Text { + region_id, + content, + typography_style, + foreground_style, + surface_template_id, + wrap_mode, + .. + } => match name.as_str() { + "region-id" => *region_id = parsed(value)?, + "content" => *content = Arc::new(parsed(value)?), + "typography-style" => *typography_style = parsed(value)?, + "foreground-style" => *foreground_style = parsed(value)?, + "surface-template-id" => *surface_template_id = parsed(value)?, + "wrap-mode" => *wrap_mode = parsed(value)?, + _ => return Err(format!("Unsupported native retained text field {name}")), + }, + LayoutNode::Flex { + direction, + wrap, + justify, + align_items, + align_content, + width, + height, + row_gap, + column_gap, + .. + } => match name.as_str() { + "direction" => *direction = parsed(value)?, + "wrap" => *wrap = parsed(value)?, + "justify" => *justify = parsed(value)?, + "align-items" => *align_items = parsed(value)?, + "align-content" => *align_content = parsed(value)?, + "width" => *width = parsed(value)?, + "height" => *height = parsed(value)?, + "row-gap" => *row_gap = parsed(value)?, + "column-gap" => *column_gap = parsed(value)?, + _ => return Err(format!("Unsupported native retained flex field {name}")), + }, + LayoutNode::Row { .. } | LayoutNode::Column { .. } => { + return Err("Native retained axis slots have no local scalar fields".to_owned()); + } + LayoutNode::NodeRef { .. } => { + return Err("Native retained node reference cannot be patched".to_owned()) + } + } + } + Ok(output) +} + +fn measured_text_work(text: &MeasuredText, property_template_count: u32) -> Result { + if text.lines.is_empty() { + return Err("Native layout measured text must contain one line".to_owned()); + } + let mut work = text.lines.len(); + for line in &text.lines { + work = work + .checked_add(line.clusters.len()) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + for cluster in &line.clusters { + if cluster.text.is_empty() { + return Err("Native layout cluster text cannot be empty".to_owned()); + } + validate_dimension("cluster width", cluster.width)?; + if cluster + .source_template_id + .is_some_and(|id| id >= property_template_count) + { + return Err( + "Native layout property template id exceeds the property template table" + .to_owned(), + ); + } + } + } + Ok(work) +} + +fn size_work(size: &Size) -> Result { + let mut work = 0; + add_vertical_size_work(size, &mut work)?; + Ok(work) +} + +fn field_work(node: &LayoutNode, name: &str) -> Result, String> { + Ok(match (node, name) { + (LayoutNode::Box { content, .. }, "content") => { + content.as_deref().map_or(Ok(Some(0)), |text| { + measured_text_work(text, u32::MAX).map(Some) + })? + } + (LayoutNode::Text { content, .. }, "content") => { + Some(measured_text_work(content, u32::MAX)?) + } + (LayoutNode::Box { height, .. }, "height") => Some(size_work(height)?), + (LayoutNode::Box { min_height, .. }, "min-height") => Some(size_work(min_height)?), + (LayoutNode::Box { max_height, .. }, "max-height") => Some(size_work(max_height)?), + (LayoutNode::Flex { height, .. }, "height") => Some(size_work(height)?), + (LayoutNode::Box { padding_top, .. }, "padding-top") => { + Some(usize::try_from(*padding_top).unwrap_or(usize::MAX)) + } + (LayoutNode::Box { padding_bottom, .. }, "padding-bottom") => { + Some(usize::try_from(*padding_bottom).unwrap_or(usize::MAX)) + } + (LayoutNode::Box { margin_top, .. }, "margin-top") => { + Some(usize::try_from(*margin_top).unwrap_or(usize::MAX)) + } + (LayoutNode::Box { margin_bottom, .. }, "margin-bottom") => { + Some(usize::try_from(*margin_bottom).unwrap_or(usize::MAX)) + } + (LayoutNode::Flex { row_gap, items, .. }, "row-gap") => Some( + usize::try_from(*row_gap) + .unwrap_or(usize::MAX) + .saturating_mul(items.len()), + ), + _ => None, + }) +} + +fn local_work_summary(node: &LayoutNode) -> Result { + const FIELDS: [&str; 9] = [ + "content", + "height", + "min-height", + "max-height", + "padding-top", + "padding-bottom", + "margin-top", + "margin-bottom", + "row-gap", + ]; + let mut values = BTreeMap::new(); + for name in FIELDS { + if let Some(value) = field_work(node, name)? { + values.insert(name, value); + } + } + Ok(LocalWorkSummary(values)) +} + +impl LocalWorkSummary { + fn changed_work(&self, fields: &JsonMap) -> usize { + fields + .keys() + .filter_map(|name| self.0.get(name.as_str())) + .copied() + .sum() + } + + fn updated( + &self, + node: &LayoutNode, + fields: &JsonMap, + ) -> Result { + let mut output = self.clone(); + for name in fields.keys() { + if let Some(value) = field_work(node, name)? { + output.0.insert( + match name.as_str() { + "content" => "content", + "height" => "height", + "min-height" => "min-height", + "max-height" => "max-height", + "padding-top" => "padding-top", + "padding-bottom" => "padding-bottom", + "margin-top" => "margin-top", + "margin-bottom" => "margin-bottom", + "row-gap" => "row-gap", + _ => continue, + }, + value, + ); + } + } + Ok(output) + } +} + +fn validate_changed_fields( + node: &LayoutNode, + fields: &JsonMap, + style_count: u32, + property_template_count: u32, +) -> Result<(), String> { + let style_field = |name: &str| { + matches!( + name, + "typography-style" + | "foreground-style" + | "background-style" + | "border-left-style" + | "border-right-style" + | "border-top-style" + | "border-bottom-style" + | "content-typography-style" + | "content-foreground-style" + ) + }; + let template_field = + |name: &str| matches!(name, "surface-template-id" | "content-surface-template-id"); + for (name, value) in fields { + if style_field(name) { + let id: Option = serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid native retained style field: {error}"))?; + if id.is_some_and(|id| id >= style_count) { + return Err("Native retained style id exceeds the style table".to_owned()); + } + } + if template_field(name) { + let id: Option = serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid native retained template field: {error}"))?; + if id.is_some_and(|id| id >= property_template_count) { + return Err("Native retained property template id exceeds the table".to_owned()); + } + } + } + match node { + LayoutNode::Box { + region_id, + content, + child, + content_region_id, + content_min_width, + width, + min_width, + max_width, + height, + min_height, + max_height, + padding_left, + padding_right, + padding_top, + padding_bottom, + margin_left, + margin_right, + margin_top, + margin_bottom, + border_left, + border_right, + scroll_offset, + .. + } => { + if fields.contains_key("region-id") && *region_id <= 0 { + return Err("Native retained box region id must be positive".to_owned()); + } + if fields.contains_key("content") { + if content.is_some() == child.is_some() { + return Err( + "Native layout box must contain exactly one text or child value".to_owned(), + ); + } + if let Some(text) = content { + measured_text_work(text, property_template_count)?; + } + } + if fields.contains_key("content-region-id") + && (content_region_id.is_some_and(|id| id <= 0) + || content_region_id.is_some() && content.is_none()) + { + return Err("Native retained content region id is invalid".to_owned()); + } + if fields.contains_key("content-min-width") { + if let Some(value) = content_min_width { + validate_dimension("content-min-width", *value)?; + } + } + for (name, size) in [ + ("width", width), + ("min-width", min_width), + ("max-width", max_width), + ("height", height), + ("min-height", min_height), + ("max-height", max_height), + ] { + if fields.contains_key(name) { + validate_size(name, size)?; + } + } + for (name, value) in [ + ("padding-left", *padding_left), + ("padding-right", *padding_right), + ("padding-top", *padding_top), + ("padding-bottom", *padding_bottom), + ("margin-left", *margin_left), + ("margin-right", *margin_right), + ("margin-top", *margin_top), + ("margin-bottom", *margin_bottom), + ("border-left", *border_left), + ("border-right", *border_right), + ("scroll-offset", *scroll_offset), + ] { + if fields.contains_key(name) { + validate_dimension(name, value)?; + } + } + } + LayoutNode::Text { + region_id, content, .. + } => { + if fields.contains_key("region-id") && *region_id <= 0 { + return Err("Native retained text region id must be positive".to_owned()); + } + if fields.contains_key("content") { + measured_text_work(content, property_template_count)?; + } + } + LayoutNode::Flex { + width, + height, + row_gap, + column_gap, + .. + } => { + if fields.contains_key("width") { + validate_size("flex width", width)?; + } + if fields.contains_key("height") { + validate_size("flex height", height)?; + } + if fields.contains_key("row-gap") { + validate_dimension("flex row gap", *row_gap)?; + } + if fields.contains_key("column-gap") { + validate_dimension("flex column gap", *column_gap)?; + } + } + LayoutNode::Row { .. } | LayoutNode::Column { .. } | LayoutNode::NodeRef { .. } => {} + } + Ok(()) +} + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub(crate) struct StyleTemplate { @@ -108,7 +1099,7 @@ struct UnderlineTemplate { color: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] // Keeping the node payload inline avoids another allocation on the hot layout path. #[allow(clippy::large_enum_variant)] #[serde( @@ -118,9 +1109,17 @@ struct UnderlineTemplate { deny_unknown_fields )] enum LayoutNode { + NodeRef { + node_id: u64, + }, Box { + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, region_id: i64, - content: Option>, + #[serde(default, deserialize_with = "deserialize_optional_arc")] + content: Option>, #[serde(default)] content_region_id: Option, #[serde(default)] @@ -129,7 +1128,12 @@ enum LayoutNode { content_foreground_style: Option, #[serde(default)] content_surface_template_id: Option, - child: Option>, + #[serde( + default, + deserialize_with = "deserialize_optional_arc", + skip_serializing + )] + child: Option>, content_width_exact: bool, #[serde(default)] content_min_width: Option, @@ -167,8 +1171,13 @@ enum LayoutNode { scroll_offset: i64, }, Text { + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, region_id: i64, - content: Box, + #[serde(deserialize_with = "deserialize_arc")] + content: Arc, #[serde(default)] typography_style: Option, foreground_style: Option, @@ -177,12 +1186,26 @@ enum LayoutNode { wrap_mode: WrapMode, }, Row { - children: Vec, + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, + #[serde(default, deserialize_with = "deserialize_arc_vec", skip_serializing)] + children: Arc>, }, Column { - children: Vec, + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, + #[serde(default, deserialize_with = "deserialize_arc_vec", skip_serializing)] + children: Arc>, }, Flex { + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, direction: FlexDirection, wrap: FlexWrap, justify: FlexAlign, @@ -192,11 +1215,12 @@ enum LayoutNode { height: Size, row_gap: i64, column_gap: i64, - items: Vec, + #[serde(default, deserialize_with = "deserialize_arc_vec", skip_serializing)] + items: Arc>, }, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] enum Size { Auto, @@ -226,7 +1250,7 @@ enum Size { }, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(transparent)] struct SizeValues(Box<[Size]>); @@ -270,7 +1294,7 @@ enum FlexAlign { SpaceEvenly, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] struct FlexItem { node: LayoutNode, @@ -320,13 +1344,13 @@ enum WrapMode { Char, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MeasuredText { lines: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MeasuredLine { clusters: Vec, @@ -2922,7 +3946,19 @@ pub fn encode_error_tape(identity: TapeIdentity, message: &str, max_bytes: usize } impl LayoutDocument { - pub fn validate(&self) -> Result<(), String> { + pub(crate) fn retained_root_p(&self) -> bool { + self.root.node_id().is_some() + } + + pub(crate) fn input_node_count(&self) -> u64 { + fn count(node: &LayoutNode) -> u64 { + let mut total = 1; + visit_layout_children(node, |child| total += count(child)); + total + } + count(&self.root) + } + fn validate_metrics(&self) -> Result<(usize, usize), String> { if self.version != LAYOUT_VERSION { return Err(format!( "Unsupported native layout IR version {}", @@ -2953,7 +3989,12 @@ impl LayoutDocument { &mut work_units, self.style_count, self.property_template_count, - ) + )?; + Ok((nodes, work_units)) + } + + pub fn validate(&self) -> Result<(), String> { + self.validate_metrics().map(|_| ()) } pub fn validate_context(&self, context: LayoutContext) -> Result<(), String> { @@ -2971,6 +4012,83 @@ impl LayoutDocument { } else { render_node_with_override( &self.root, + None, + context, + false, + root_width_override.map(|declared_width| BoxOverride { + declared_width: Some(declared_width), + ..BoxOverride::default() + }), + ) + .map(|rendered| rendered.into_tape(self.style_count)) + } + } +} + +impl RetainedDocument { + pub(crate) fn validate_context(&self, context: LayoutContext) -> Result<(), String> { + fn add_retained_context_work( + node: &LayoutNode, + document: &RetainedDocument, + context: LayoutContext, + work_units: &mut usize, + ) -> Result<(), String> { + let node = document.resolve(node)?; + match node { + LayoutNode::NodeRef { .. } => unreachable!("resolver returned a node reference"), + LayoutNode::Text { .. } => {} + LayoutNode::Box { + child, + height, + min_height, + max_height, + .. + } => { + for size in [height, min_height, max_height] { + add_context_size_work(size, context, work_units)?; + } + if let Some(child) = child { + add_retained_context_work(child, document, context, work_units)?; + } + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + for child in children.iter() { + add_retained_context_work(child, document, context, work_units)?; + } + } + LayoutNode::Flex { height, items, .. } => { + add_context_size_work(height, context, work_units)?; + for item in items.iter() { + add_retained_context_work(&item.node, document, context, work_units)?; + } + } + } + Ok(()) + } + + let root = self + .effective_node(self.root_id) + .ok_or_else(|| "Native retained document lost its root".to_owned())?; + RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + let mut work_units = 0; + add_retained_context_work(root, self, context, &mut work_units) + } + + pub(crate) fn layout_tape( + &self, + context: LayoutContext, + root_width_override: Option, + ) -> Result { + let root = self + .effective_node(self.root_id) + .ok_or_else(|| "Native retained document lost its root".to_owned())?; + RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + if root_width_override.is_some() && !matches!(root, LayoutNode::Box { .. }) { + Err("Native root width override requires a box root".to_owned()) + } else { + render_node_with_override( + root, + Some(self), context, false, root_width_override.map(|declared_width| BoxOverride { @@ -3056,6 +4174,9 @@ fn add_context_work( work_units: &mut usize, ) -> Result<(), String> { match node { + LayoutNode::NodeRef { .. } => { + return Err("Native full layout cannot contain a node reference".to_owned()); + } LayoutNode::Text { .. } => {} LayoutNode::Box { child, @@ -3071,14 +4192,14 @@ fn add_context_work( add_context_work(child, context, work_units)?; } } - LayoutNode::Row { children } | LayoutNode::Column { children } => { - for child in children { + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + for child in children.iter() { add_context_work(child, context, work_units)?; } } LayoutNode::Flex { height, items, .. } => { add_context_size_work(height, context, work_units)?; - for item in items { + for item in items.iter() { add_context_work(&item.node, context, work_units)?; } } @@ -3129,6 +4250,9 @@ fn validate_node( } add_work_units(work_units, 1)?; match node { + LayoutNode::NodeRef { .. } => { + return Err("Native full layout cannot contain a node reference".to_owned()); + } LayoutNode::Text { region_id, content, @@ -3332,11 +4456,11 @@ fn validate_node( )?; } } - LayoutNode::Row { children } | LayoutNode::Column { children } => { + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { if children.is_empty() { return Err("Native layout container must contain a child".to_owned()); } - for child in children { + for child in children.iter() { validate_node( child, depth + 1, @@ -3369,7 +4493,7 @@ fn validate_node( .unwrap_or(usize::MAX) .saturating_mul(items.len()), )?; - for item in items { + for item in items.iter() { if !item.grow.is_finite() || item.grow < 0.0 || !item.shrink.is_finite() @@ -4078,6 +5202,7 @@ fn box_vertical_side(node: &LayoutNode) -> Option { fn box_content_intrinsics( node: &LayoutNode, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -4100,7 +5225,7 @@ fn box_content_intrinsics( // current inline viewport. Treating that width as unknown makes // responsive descendants collapse to their narrow intrinsic form and // produces a different automatic minimum from the visible renderer. - let rendered = render_node(child, context, true)?; + let rendered = render_node(child, resolver, context, true)?; Ok(Some(( content_min_width.unwrap_or_else(|| rendered.min_content_width(*wrap_mode)), rendered.max_width(), @@ -4114,6 +5239,7 @@ fn flex_box_resolve_width( node: &LayoutNode, size: &Size, fallback: Option, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -4127,7 +5253,8 @@ fn flex_box_resolve_width( else { return Ok(fallback); }; - let (min_content, max_content) = box_content_intrinsics(node, context)?.unwrap_or((0, 0)); + let (min_content, max_content) = + box_content_intrinsics(node, resolver, context)?.unwrap_or((0, 0)); let side = box_horizontal_side(node).unwrap_or(0); let stretch = context .viewport_width_known @@ -4176,6 +5303,7 @@ fn flex_min_main( source: &LayoutNode, rendered: &Rendered, axis: FlexAxis, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result { let LayoutNode::Box { @@ -4195,13 +5323,17 @@ fn flex_min_main( FlexAxis::Row => { let side = box_horizontal_side(source).unwrap_or(0); let declared = - flex_box_resolve_width(source, min_width, Some(0), context)?.unwrap_or(0); + flex_box_resolve_width(source, min_width, Some(0), resolver, context)?.unwrap_or(0); if *wrap_mode == WrapMode::None { rendered.max_width().max(side + declared) } else { let content_min = match content_min_width { Some(content_min_width) => *content_min_width, - None => box_content_intrinsics(source, context)?.unwrap_or((0, 0)).0, + None => { + box_content_intrinsics(source, resolver, context)? + .unwrap_or((0, 0)) + .0 + } }; side + declared.max(content_min) } @@ -4218,6 +5350,7 @@ fn flex_min_main( fn flex_max_main( source: &LayoutNode, axis: FlexAxis, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -4229,7 +5362,7 @@ fn flex_max_main( return Ok(None); }; Ok(match axis { - FlexAxis::Row => flex_box_resolve_width(source, max_width, None, context)? + FlexAxis::Row => flex_box_resolve_width(source, max_width, None, resolver, context)? .map(|value| value + box_horizontal_side(source).unwrap_or(0)), FlexAxis::Column => flex_box_resolve_height(source, max_height, None, context) .map(|value| value + box_vertical_side(source).unwrap_or(0)), @@ -4257,6 +5390,7 @@ fn flex_basis_main( rendered: &Rendered, axis: FlexAxis, basis: &Size, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result { let rendered_main = match axis { @@ -4268,14 +5402,15 @@ fn flex_basis_main( } if matches!(basis, Size::Content) { if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { - let (_, content_max) = box_content_intrinsics(source, context)?.unwrap_or((0, 0)); + let (_, content_max) = + box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); return Ok(box_horizontal_side(source).unwrap_or(0) + content_max); } return Ok(rendered_main); } if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { - let (_, content_max) = box_content_intrinsics(source, context)?.unwrap_or((0, 0)); - let content = flex_box_resolve_width(source, basis, Some(content_max), context)? + let (_, content_max) = box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); + let content = flex_box_resolve_width(source, basis, Some(content_max), resolver, context)? .unwrap_or(content_max); return Ok(box_horizontal_side(source).unwrap_or(0) + content); } @@ -4309,9 +5444,14 @@ fn measure_flex_item<'a>( item: &'a FlexItem, axis: FlexAxis, inline_viewport: Option, + resolver: Option<&'a RetainedDocument>, context: LayoutContext, ) -> Result, String> { - let uses_inline_viewport = flex_item_uses_inline_viewport(&item.node); + let source = match resolver { + Some(resolver) => resolver.resolve(&item.node)?, + None => &item.node, + }; + let uses_inline_viewport = flex_item_uses_inline_viewport(source); let measurement_context = LayoutContext { viewport_width: if uses_inline_viewport { inline_viewport.unwrap_or(0) @@ -4322,13 +5462,13 @@ fn measure_flex_item<'a>( viewport_height: context.viewport_height, inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; - let rendered = render_node(&item.node, measurement_context, uses_inline_viewport)?; - let min_main = flex_min_main(&item.node, &rendered, axis, context)?; - let max_main = flex_max_main(&item.node, axis, context)?; - let base = flex_basis_main(&item.node, &rendered, axis, &item.basis, context)?.max(0); + let rendered = render_node(source, resolver, measurement_context, uses_inline_viewport)?; + let min_main = flex_min_main(source, &rendered, axis, resolver, context)?; + let max_main = flex_max_main(source, axis, resolver, context)?; + let base = flex_basis_main(source, &rendered, axis, &item.basis, resolver, context)?.max(0); let hypothetical = flex_clamp_main(base, min_main, max_main); Ok(FlexRuntimeItem { - source: &item.node, + source, grow: item.grow, shrink: item.shrink, align_self: item.align_self, @@ -4665,12 +5805,14 @@ fn box_override_for_flex( }) } +#[allow(clippy::too_many_arguments)] fn render_flex_sized_entry( item: &FlexRuntimeItem<'_>, axis: FlexAxis, main: i64, cross: Option, container_align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -4693,8 +5835,13 @@ fn render_flex_sized_entry( inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; let override_size = box_override_for_flex(item.source, axis, main, cross, stretch); - let mut rendered = - render_node_with_override(item.source, render_context, intrinsic, override_size)?; + let mut rendered = render_node_with_override( + item.source, + resolver, + render_context, + intrinsic, + override_size, + )?; match axis { FlexAxis::Row => { rendered = pad_rendered_width(rendered, main, FlexAlign::FlexStart); @@ -4794,8 +5941,16 @@ fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { Rendered { lines, breaks } } -fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option { +fn exact_rendered_height( + node: &LayoutNode, + resolver: Option<&RetainedDocument>, + context: LayoutContext, +) -> Option { + let node = resolver + .and_then(|value| value.resolve(node).ok()) + .unwrap_or(node); match node { + LayoutNode::NodeRef { .. } => None, LayoutNode::Text { content, .. } => i64::try_from(content.lines.len()).ok(), LayoutNode::Box { height, @@ -4824,15 +5979,15 @@ fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option children + LayoutNode::Row { children, .. } => children .iter() - .map(|child| exact_rendered_height(child, context)) + .map(|child| exact_rendered_height(child, resolver, context)) .try_fold(1_i64, |maximum, height| { height.map(|height| maximum.max(height)) }), - LayoutNode::Column { children } => children + LayoutNode::Column { children, .. } => children .iter() - .map(|child| exact_rendered_height(child, context)) + .map(|child| exact_rendered_height(child, resolver, context)) .try_fold(0_i64, |total, height| { height.and_then(|height| total.checked_add(height)) }), @@ -4842,25 +5997,31 @@ fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option, context: LayoutContext, intrinsic: bool, start: i64, height: i64, ) -> Option> { - let total_height = exact_rendered_height(node, context)?; + let node = resolver + .and_then(|value| value.resolve(node).ok()) + .unwrap_or(node); + let total_height = exact_rendered_height(node, resolver, context)?; if start <= 0 && height >= total_height { - return Some(render_node(node, context, intrinsic)); + return Some(render_node(node, resolver, context, intrinsic)); } match node { - LayoutNode::Column { children } + LayoutNode::Column { children, .. } if !intrinsic && !context.inline_auto_width_intrinsic && context.viewport_width_known => { - Some(render_column_window(children, context, start, height)) + Some(render_column_window( + children, resolver, context, start, height, + )) } _ => Some( - render_node(node, context, intrinsic) + render_node(node, resolver, context, intrinsic) .map(|rendered| slice_rendered(rendered, start, height)), ), } @@ -4868,6 +6029,7 @@ fn render_node_window( fn render_column_window( children: &[LayoutNode], + resolver: Option<&RetainedDocument>, context: LayoutContext, start: i64, height: i64, @@ -4880,7 +6042,7 @@ fn render_column_window( let mut parts = Vec::new(); for child in leaves { - let child_height = exact_rendered_height(child, context) + let child_height = exact_rendered_height(child, resolver, context) .ok_or_else(|| "Native layout column window has an unbounded child".to_owned())?; let child_end = offset.saturating_add(child_height); if child_end <= start { @@ -4894,13 +6056,20 @@ fn render_column_window( let child_start = start.saturating_sub(offset); let child_window_height = (child_end.min(end) - (offset + child_start)).max(0); let mut rendered = if child_start == 0 && child_window_height >= child_height { - render_node(child, context, false)? + render_node(child, resolver, context, false)? } else { - render_node_window(child, context, false, child_start, child_window_height) - .unwrap_or_else(|| { - render_node(child, context, false) - .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) - })? + render_node_window( + child, + resolver, + context, + false, + child_start, + child_window_height, + ) + .unwrap_or_else(|| { + render_node(child, resolver, context, false) + .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) + })? }; let extra = (target - rendered.first_width()).max(0); @@ -4924,6 +6093,7 @@ fn flex_line_cross( line: &[FlexRuntimeItem<'_>], axis: FlexAxis, container_align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -4936,6 +6106,7 @@ fn flex_line_cross( item.target, None, container_align, + resolver, context, intrinsic, )? @@ -4992,6 +6163,7 @@ fn render_flex_row_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5006,6 +6178,7 @@ fn render_flex_row_line( item.target, Some(line_cross), align, + resolver, context, intrinsic, )?; @@ -5028,6 +6201,7 @@ fn render_flex_column_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5049,6 +6223,7 @@ fn render_flex_column_line( item.target, Some(line_cross), align, + resolver, context, intrinsic, )? @@ -5075,6 +6250,7 @@ fn render_flex_row( align: FlexAlign, align_content: FlexAlign, single_line: bool, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5093,7 +6269,7 @@ fn render_flex_row( } else { lines .iter() - .map(|line| flex_line_cross(line, FlexAxis::Row, align, context, intrinsic)) + .map(|line| flex_line_cross(line, FlexAxis::Row, align, resolver, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -5119,6 +6295,7 @@ fn render_flex_row( main_gap, justify, align, + resolver, context, intrinsic, )?); @@ -5149,6 +6326,7 @@ fn render_flex_column( align: FlexAlign, align_content: FlexAlign, single_line: bool, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5160,7 +6338,9 @@ fn render_flex_column( } else { lines .iter() - .map(|line| flex_line_cross(line, FlexAxis::Column, align, context, intrinsic)) + .map(|line| { + flex_line_cross(line, FlexAxis::Column, align, resolver, context, intrinsic) + }) .collect::, _>>()? }; let cross_layout = @@ -5176,7 +6356,7 @@ fn render_flex_column( .enumerate() { let rendered = render_flex_column_line( - line, line_cross, main_size, main_gap, justify, align, context, intrinsic, + line, line_cross, main_size, main_gap, justify, align, resolver, context, intrinsic, )?; parts.push((rendered, line_cross)); if index + 1 < line_count { @@ -5205,6 +6385,7 @@ fn render_flex( row_gap: i64, column_gap: i64, source_items: &[FlexItem], + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5220,7 +6401,15 @@ fn render_flex( indices.sort_by_key(|index| (source_items[*index].order, *index)); let mut items = indices .into_iter() - .map(|index| measure_flex_item(&source_items[index], axis, inline_viewport, context)) + .map(|index| { + measure_flex_item( + &source_items[index], + axis, + inline_viewport, + resolver, + context, + ) + }) .collect::, _>>()?; if flex_direction_reversed(direction) { items.reverse(); @@ -5245,6 +6434,7 @@ fn render_flex( align_items, align_content, single_line, + resolver, context, intrinsic, ), @@ -5258,6 +6448,7 @@ fn render_flex( align_items, align_content, single_line, + resolver, context, intrinsic, ), @@ -5273,18 +6464,24 @@ struct BoxOverride { fn render_node( node: &LayoutNode, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { - render_node_with_override(node, context, intrinsic, None) + render_node_with_override(node, resolver, context, intrinsic, None) } fn render_node_with_override( node: &LayoutNode, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, size_override: Option, ) -> Result { + let node = match resolver { + Some(resolver) => resolver.resolve(node)?, + None => node, + }; #[cfg(test)] TEST_RENDER_NODE_COUNT.with(|count| { if let Some(value) = count.get() { @@ -5292,6 +6489,7 @@ fn render_node_with_override( } }); match node { + LayoutNode::NodeRef { .. } => Err("Native unresolved retained node reference".to_owned()), LayoutNode::Box { region_id, content, @@ -5332,6 +6530,7 @@ fn render_node_with_override( overflow, wrap_mode, scroll_offset, + .. } => { let declared_width_override = size_override.and_then(|override_size| override_size.declared_width); @@ -5445,7 +6644,9 @@ fn render_node_with_override( && matches!(effective_width, Size::Auto), }; if simple_scroll_window { - if let Some(total_height) = exact_rendered_height(child, child_context) { + if let Some(total_height) = + exact_rendered_height(child, resolver, child_context) + { let content_height = definite_content_height.expect("checked above"); let max_offset = (total_height - content_height).max(0); let start = (*scroll_offset).max(0).min(max_offset); @@ -5454,6 +6655,7 @@ fn render_node_with_override( Some( render_node_window( child, + resolver, child_context, intrinsic || intrinsic_child, start, @@ -5464,6 +6666,7 @@ fn render_node_with_override( } else { Some(render_node( child, + resolver, child_context, intrinsic || intrinsic_child, )?) @@ -5471,6 +6674,7 @@ fn render_node_with_override( } else { Some(render_node( child, + resolver, child_context, intrinsic || intrinsic_child, )?) @@ -5807,6 +7011,7 @@ fn render_node_with_override( foreground_style, surface_template_id, wrap_mode, + .. } => { let width = measured_max_width(content); let mut lines = measured_lines(content, width, *wrap_mode); @@ -5818,14 +7023,14 @@ fn render_node_with_override( } Ok(Rendered::from_lines(lines)) } - LayoutNode::Row { children } => { + LayoutNode::Row { children, .. } => { let child_context = LayoutContext { inline_auto_width_intrinsic: true, ..context }; let rendered = children .iter() - .map(|child| render_node(child, child_context, intrinsic)) + .map(|child| render_node(child, resolver, child_context, intrinsic)) .collect::, _>>()?; let height = rendered .iter() @@ -5846,12 +7051,12 @@ fn render_node_with_override( } Ok(Rendered::from_lines(lines)) } - LayoutNode::Column { children } => { + LayoutNode::Column { children, .. } => { let mut leaves = Vec::new(); collect_column_leaves(children, &mut leaves); let rendered = leaves .into_iter() - .map(|child| render_node(child, context, intrinsic)) + .map(|child| render_node(child, resolver, context, intrinsic)) .collect::, _>>()?; let maximum = rendered .iter() @@ -5891,6 +7096,7 @@ fn render_node_with_override( row_gap, column_gap, items, + .. } => render_flex( *direction, *wrap, @@ -5902,6 +7108,7 @@ fn render_node_with_override( *row_gap, *column_gap, items, + resolver, context, intrinsic, ), @@ -5910,7 +7117,7 @@ fn render_node_with_override( fn collect_column_leaves<'a>(children: &'a [LayoutNode], output: &mut Vec<&'a LayoutNode>) { for child in children { - if let LayoutNode::Column { children } = child { + if let LayoutNode::Column { children, .. } = child { collect_column_leaves(children, output); } else { output.push(child); @@ -5973,8 +7180,10 @@ mod tests { surface_template_id: Option, ) -> LayoutNode { LayoutNode::Box { + node_id: None, + node_revision: None, region_id, - content: Some(Box::new(content)), + content: Some(Arc::new(content)), content_region_id: None, content_typography_style: None, content_foreground_style: None, @@ -6034,13 +7243,15 @@ mod tests { surface_template_id: Option, ) -> LayoutNode { LayoutNode::Box { + node_id: None, + node_revision: None, region_id, content: None, content_region_id: None, content_typography_style: None, content_foreground_style: None, content_surface_template_id: None, - child: Some(Box::new(child)), + child: Some(Arc::new(child)), content_width_exact: true, content_min_width: None, width: Size::Content, @@ -6076,23 +7287,79 @@ mod tests { } } + fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { + match &mut node { + LayoutNode::Box { + node_id: id, + node_revision, + .. + } + | LayoutNode::Text { + node_id: id, + node_revision, + .. + } + | LayoutNode::Row { + node_id: id, + node_revision, + .. + } + | LayoutNode::Column { + node_id: id, + node_revision, + .. + } + | LayoutNode::Flex { + node_id: id, + node_revision, + .. + } => { + *id = Some(node_id); + *node_revision = Some(revision); + } + LayoutNode::NodeRef { .. } => panic!("cannot identify a retained node reference"), + } + node + } + + fn retained_document(root: LayoutNode) -> LayoutDocument { + LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 0, + styles: Vec::new(), + root, + } + } + fn fixed_scroll_column_document(scroll_offset: i64) -> LayoutDocument { - let children = (0..20) - .map(|index| { - let mut node = text_box( - index + 2, - measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), - Some((index % 2) as u32), - ); - let LayoutNode::Box { width, height, .. } = &mut node else { - unreachable!(); - }; - *width = Size::Viewport; - *height = Size::Lines { value: 1 }; - node - }) - .collect(); - let mut root = child_box(1, LayoutNode::Column { children }, Some(2)); + let children = Arc::new( + (0..20) + .map(|index| { + let mut node = text_box( + index + 2, + measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), + Some((index % 2) as u32), + ); + let LayoutNode::Box { width, height, .. } = &mut node else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 1 }; + node + }) + .collect(), + ); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children, + }, + Some(2), + ); let LayoutNode::Box { width, height, @@ -6234,6 +7501,8 @@ mod tests { *content_width_exact = false; *text_align = HorizontalAlign::Center; let column = LayoutNode::Flex { + node_id: None, + node_revision: None, direction: FlexDirection::Column, wrap: FlexWrap::Nowrap, justify: FlexAlign::FlexStart, @@ -6243,14 +7512,14 @@ mod tests { height: Size::Auto, row_gap: 0, column_gap: 0, - items: vec![FlexItem { + items: Arc::new(vec![FlexItem { node: label, order: 0, grow: 0.0, shrink: 0.0, basis: Size::Auto, align_self: FlexAlign::Stretch, - }], + }]), }; let mut outer = child_box(1, column, None); let LayoutNode::Box { @@ -6265,6 +7534,7 @@ mod tests { *content_width_exact = true; let rendered = render_node_with_override( &outer, + None, test_context(), false, Some(BoxOverride { @@ -7202,22 +8472,32 @@ mod tests { #[test] fn fixed_height_scroll_layout_does_not_render_offscreen_column_suffix() { - let children = (0..200) - .map(|index| { - let mut node = text_box( - index + 2, - measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), - None, - ); - let LayoutNode::Box { width, height, .. } = &mut node else { - unreachable!(); - }; - *width = Size::Viewport; - *height = Size::Lines { value: 1 }; - node - }) - .collect(); - let mut root = child_box(1, LayoutNode::Column { children }, None); + let children = Arc::new( + (0..200) + .map(|index| { + let mut node = text_box( + index + 2, + measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), + None, + ); + let LayoutNode::Box { width, height, .. } = &mut node else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 1 }; + node + }) + .collect(), + ); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children, + }, + None, + ); let LayoutNode::Box { width, height, @@ -7252,22 +8532,26 @@ mod tests { #[test] fn row_auto_children_do_not_duplicate_column_viewport() { let row = LayoutNode::Row { - children: vec![ + node_id: None, + node_revision: None, + children: Arc::new(vec![ auto_text_box(2, measured_text(vec![vec![cluster("Left", 4, None)]]), None), auto_text_box( 3, measured_text(vec![vec![cluster("Right", 5, None)]]), None, ), - ], + ]), }; let mut root = child_box( 1, LayoutNode::Column { - children: vec![ + node_id: None, + node_revision: None, + children: Arc::new(vec![ row, auto_text_box(4, measured_text(vec![vec![cluster("Body", 4, None)]]), None), - ], + ]), }, None, ); @@ -7540,4 +8824,217 @@ mod tests { ); assert!(fragment_style_delta(&old, &changed).is_none()); } + + #[test] + fn retained_scalar_patch_path_copies_only_radix_and_shares_wide_edges() { + let children = Arc::new( + (0..512) + .map(|index| { + identified( + text_box( + index + 2, + measured_text(vec![vec![cluster("x", 1, None)]]), + None, + ), + index as u64 + 2, + 1, + ) + }) + .collect(), + ); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children, + }, + None, + ), + 1, + 7, + ); + let (base, parsed) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + assert_eq!(parsed, 514, "anonymous content-layout is also parsed input"); + let base_root = radix_lookup(&base.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(base_child), + .. + } = base_root.node.as_ref() + else { + panic!("expected retained box root"); + }; + let delta: DocumentDelta = serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": 7, + "target-revision": 19, + "slot-patches": [{"slot": 0, "local": {"padding-left": 1}}] + }] + })) + .unwrap(); + let (target, validated, copied) = base.apply_delta(delta).unwrap(); + assert_eq!(validated, 1); + assert_eq!(copied, 17); + let target_root = radix_lookup(&target.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(target_child), + padding_left, + .. + } = target_root.node.as_ref() + else { + panic!("expected patched box root"); + }; + assert_eq!(*padding_left, 1); + assert!(Arc::ptr_eq(base_child, target_child)); + let LayoutNode::Box { padding_left, .. } = base_root.node.as_ref() else { + unreachable!(); + }; + assert_eq!(*padding_left, 0, "persistent parent was mutated"); + } + + #[test] + fn retained_revision_only_shares_body_and_fork_roots() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("old", 3, None)]]), None), + 1, + 3, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let revision_only = |expected, target| { + serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": expected, + "target-revision": target + }] + })) + .unwrap() + }; + let (left, _, copied) = base.apply_delta(revision_only(3, 20)).unwrap(); + let (right, _, _) = base.apply_delta(revision_only(3, 30)).unwrap(); + assert_eq!(copied, 17); + assert!(Arc::ptr_eq( + &radix_lookup(&base.entries, 1).unwrap().node, + &radix_lookup(&left.entries, 1).unwrap().node, + )); + assert_eq!(radix_lookup(&left.entries, 1).unwrap().revision, 20); + assert_eq!(radix_lookup(&right.entries, 1).unwrap().revision, 30); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 3); + } + + #[test] + fn retained_bootstrap_rejects_duplicate_ancestor_identity() { + let child = identified( + text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 2, + ); + let root = identified(child_box(1, child, None), 1, 1); + assert!(RetainedDocument::bootstrap(retained_document(root)) + .unwrap_err() + .contains("duplicate node id")); + } + + #[test] + fn retained_delta_rejects_invalid_local_value_without_mutating_parent() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 4, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let invalid = serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": 4, + "target-revision": 5, + "slot-patches": [{"slot": 0, "local": {"padding-left": -1}}] + }] + })) + .unwrap(); + assert!(base + .apply_delta(invalid) + .unwrap_err() + .contains("cannot be negative")); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 4); + } + + #[test] + fn retained_work_limit_validates_the_final_batch_independent_of_entry_order() { + let mut first = text_box(2, measured_text(vec![vec![cluster("a", 1, None)]]), None); + let mut second = text_box(3, measured_text(vec![vec![cluster("b", 1, None)]]), None); + let LayoutNode::Box { + padding_top: first_padding, + .. + } = &mut first + else { + unreachable!(); + }; + *first_padding = 0; + let LayoutNode::Box { + padding_top: second_padding, + .. + } = &mut second + else { + unreachable!(); + }; + *second_padding = (MAX_LAYOUT_WORK_UNITS - 8) as i64; + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![identified(first, 2, 1), identified(second, 3, 1)]), + }, + None, + ), + 1, + 1, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let entry = |node_id: u64, padding_top: usize| { + serde_json::json!({ + "node-id": node_id, + "expected-revision": 1, + "target-revision": 2, + "slot-patches": [{"slot": 0, "local": {"padding-top": padding_top}}] + }) + }; + let delta = |entries: Vec| { + serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": entries + })) + .unwrap() + }; + let high = MAX_LAYOUT_WORK_UNITS - 18; + let forward = base + .apply_delta(delta(vec![entry(2, 10), entry(3, high)])) + .unwrap() + .0; + let reverse = base + .apply_delta(delta(vec![entry(3, high), entry(2, 10)])) + .unwrap() + .0; + assert_eq!(forward.work_units, MAX_LAYOUT_WORK_UNITS); + assert_eq!(reverse.work_units, MAX_LAYOUT_WORK_UNITS); + } } diff --git a/native/src/lib.rs b/native/src/lib.rs index 1464fa3..f32f597 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -1,8 +1,8 @@ mod layout; use layout::{ - encode_error_tape, LayoutContext, LayoutDocument, LayoutTape, TapeIdentity, TapeOutputOptions, - MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, + encode_error_tape, DocumentDelta, LayoutContext, LayoutDocument, LayoutTape, RetainedDocument, + TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, }; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -324,6 +324,8 @@ struct ControlBatch { version: u32, #[serde(default)] document: Option, + #[serde(default, rename = "document-delta")] + document_delta: Option, #[serde(default, rename = "document-base-revision")] document_base_revision: Option, #[serde(default, rename = "document-target-revision")] @@ -375,7 +377,7 @@ struct ControlFrame { enum JobPayload { Echo(Vec), Layout { - document: Arc, + document: LayoutSource, context: LayoutContext, root_width: i64, root_width_override: bool, @@ -388,9 +390,43 @@ enum JobPayload { root_metadata: bool, document_base_revision: u64, document_target_revision: u64, + validation_resolver_lookups: u64, }, } +#[derive(Clone, Debug)] +enum LayoutSource { + Full(Arc), + Retained(Arc), +} + +impl LayoutSource { + fn validate_context(&self, context: LayoutContext) -> Result<(), String> { + match self { + Self::Full(document) => document.validate_context(context), + Self::Retained(document) => document.validate_context(context), + } + } + + fn layout_tape( + &self, + context: LayoutContext, + root_width: Option, + ) -> Result { + match self { + Self::Full(document) => document.layout_tape(context, root_width), + Self::Retained(document) => document.layout_tape(context, root_width), + } + } + + fn styles(&self) -> Result, String> { + match self { + Self::Full(document) => Ok(document.styles.clone()), + Self::Retained(document) => document.styles(), + } + } +} + #[derive(Clone, Debug)] struct BaselineIdentity { context: LayoutContext, @@ -404,7 +440,7 @@ struct BaselineIdentity { #[derive(Clone, Debug)] struct ConfirmedBaseline { identity: BaselineIdentity, - document: Arc, + document: LayoutSource, document_revision: u64, tape: LayoutTape, styles: Vec, @@ -413,7 +449,7 @@ struct ConfirmedBaseline { #[derive(Debug)] struct PendingBaseline { confirmed_identity: BaselineIdentity, - document: Arc, + document: LayoutSource, document_revision: u64, tape: LayoutTape, styles: Vec, @@ -431,6 +467,20 @@ struct RenderedJob { baseline_hit: bool, base_renders: u64, target_renders: u64, + resolver_lookups: u64, +} + +#[derive(Default)] +struct DocumentInputStats { + parses: u64, + validations: u64, + reuses: u64, + full_input_bytes: u64, + delta_input_bytes: u64, + full_nodes_parsed: u64, + delta_entries_parsed: u64, + delta_entries_validated: u64, + trie_path_nodes_copied: u64, } #[derive(Debug)] @@ -463,6 +513,13 @@ struct RuntimeState { document_parses: u64, document_validations: u64, document_reuses: u64, + document_full_input_bytes: u64, + document_delta_input_bytes: u64, + document_full_nodes_parsed: u64, + document_delta_entries_parsed: u64, + document_delta_entries_validated: u64, + document_trie_path_nodes_copied: u64, + document_resolver_lookups: u64, confirmed_baseline: Option>, } @@ -491,7 +548,7 @@ struct Shared { struct Session { shared: Arc, workers: Mutex>>>, - layout_document: Mutex>>, + layout_document: Mutex>, worker_count: usize, } @@ -516,6 +573,13 @@ struct SessionStats { document_parses: u64, document_validations: u64, document_reuses: u64, + document_full_input_bytes: u64, + document_delta_input_bytes: u64, + document_full_nodes_parsed: u64, + document_delta_entries_parsed: u64, + document_delta_entries_validated: u64, + document_trie_path_nodes_copied: u64, + document_resolver_lookups: u64, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -581,6 +645,13 @@ impl Session { document_parses: 0, document_validations: 0, document_reuses: 0, + document_full_input_bytes: 0, + document_delta_input_bytes: 0, + document_full_nodes_parsed: 0, + document_delta_entries_parsed: 0, + document_delta_entries_validated: 0, + document_trie_path_nodes_copied: 0, + document_resolver_lookups: 0, confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -611,7 +682,7 @@ impl Session { .unwrap_or_else(|poison| poison.into_inner()) .confirmed_baseline .as_ref() - .map(|baseline| Arc::clone(&baseline.document)); + .map(|baseline| baseline.document.clone()); Ok(Box::new(Self { shared, workers: Mutex::new(Some(handles)), @@ -643,6 +714,9 @@ impl Session { fn submit(&self, generation: u64, payload: &[u8]) -> Result { let batch = parse_control_batch(payload)?; + if batch.document_delta.is_some() { + return Err("Native async reflow does not accept retained document deltas".to_owned()); + } if !self.shared.alive.load(Ordering::Acquire) { return Err("Native reflow session is closed".to_owned()); } @@ -672,12 +746,12 @@ impl Session { let document = match batch.document { Some(document) => { document.validate()?; - Some(Arc::new(document)) + Some(LayoutSource::Full(Arc::new(document))) } None if layout_requested => Some( confirmed_baseline .as_ref() - .map(|baseline| Arc::clone(&baseline.document)) + .map(|baseline| baseline.document.clone()) .or_else(|| { self.layout_document .lock() @@ -852,10 +926,13 @@ impl Session { .unwrap_or_else(|poison| poison.into_inner()) .confirmed_baseline .clone(); - let (document, document_parses, document_validations, document_reuses) = match batch - .document - { - Some(document) => { + let (document, input_stats) = match (batch.document, batch.document_delta) { + (Some(_), Some(_)) => { + return Err( + "Native retained render cannot combine document and document-delta".to_owned(), + ); + } + (Some(document), None) => { if document_base_revision.checked_add(1) != Some(document_target_revision) { return Err( "Native retained document replacement must advance one revision".to_owned(), @@ -876,10 +953,60 @@ impl Session { } _ => {} } - document.validate()?; - (Arc::new(document), 1, 1, 0) + let retained = document.retained_root_p(); + let (source, node_count) = if retained { + let (document, node_count) = RetainedDocument::bootstrap(document)?; + (LayoutSource::Retained(document), node_count) + } else { + document.validate()?; + let node_count = document.input_node_count(); + (LayoutSource::Full(Arc::new(document)), node_count) + }; + ( + source, + DocumentInputStats { + parses: 1, + validations: 1, + full_input_bytes: payload.len() as u64, + full_nodes_parsed: node_count, + ..DocumentInputStats::default() + }, + ) } - None => { + (None, Some(delta)) => { + if document_base_revision.checked_add(1) != Some(document_target_revision) { + return Err( + "Native retained document delta must advance one revision".to_owned() + ); + } + let baseline = confirmed.as_ref().ok_or_else(|| { + "Native retained document delta requires a confirmed baseline".to_owned() + })?; + if baseline.document_revision != document_base_revision { + return Err( + "Native retained document delta base revision does not match confirmed state" + .to_owned(), + ); + } + let LayoutSource::Retained(document) = &baseline.document else { + return Err( + "Native retained document delta requires an identified bootstrap" + .to_owned(), + ); + }; + let (document, parsed, copied) = document.apply_delta(delta)?; + ( + LayoutSource::Retained(document), + DocumentInputStats { + delta_input_bytes: payload.len() as u64, + delta_entries_parsed: parsed, + delta_entries_validated: parsed, + trie_path_nodes_copied: copied, + ..DocumentInputStats::default() + }, + ) + } + (None, None) => { if document_base_revision != document_target_revision { return Err( "Native retained document reuse requires equal base and target revisions" @@ -902,7 +1029,13 @@ impl Session { .to_owned(), ); } - (Arc::clone(&baseline.document), 0, 0, 1) + ( + baseline.document.clone(), + DocumentInputStats { + reuses: 1, + ..DocumentInputStats::default() + }, + ) } }; let frame = batch @@ -943,9 +1076,16 @@ impl Session { } state.base_renders += output.base_renders; state.target_renders += output.target_renders; - state.document_parses += document_parses; - state.document_validations += document_validations; - state.document_reuses += document_reuses; + state.document_resolver_lookups += output.resolver_lookups; + state.document_parses += input_stats.parses; + state.document_validations += input_stats.validations; + state.document_reuses += input_stats.reuses; + state.document_full_input_bytes += input_stats.full_input_bytes; + state.document_delta_input_bytes += input_stats.delta_input_bytes; + state.document_full_nodes_parsed += input_stats.full_nodes_parsed; + state.document_delta_entries_parsed += input_stats.delta_entries_parsed; + state.document_delta_entries_validated += input_stats.delta_entries_validated; + state.document_trie_path_nodes_copied += input_stats.trie_path_nodes_copied; state.pending_baselines.clear(); if let Some(pending) = output.pending { state @@ -1078,6 +1218,13 @@ impl Session { document_parses: state.document_parses, document_validations: state.document_validations, document_reuses: state.document_reuses, + document_full_input_bytes: state.document_full_input_bytes, + document_delta_input_bytes: state.document_delta_input_bytes, + document_full_nodes_parsed: state.document_full_nodes_parsed, + document_delta_entries_parsed: state.document_delta_entries_parsed, + document_delta_entries_validated: state.document_delta_entries_validated, + document_trie_path_nodes_copied: state.document_trie_path_nodes_copied, + document_resolver_lookups: state.document_resolver_lookups, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1129,7 +1276,7 @@ fn parse_control_batch(payload: &[u8]) -> Result { } fn checked_layout_context( - document: &LayoutDocument, + document: &LayoutSource, frame_key: i64, viewport_width: i64, viewport_width_known: bool, @@ -1179,11 +1326,12 @@ fn confirmed_baseline_matches( } fn prepare_layout_job( - document: &Arc, + document: &LayoutSource, frame: ControlFrame, document_base_revision: u64, document_target_revision: u64, ) -> Result { + layout::reset_resolver_lookups(); if frame.payload.is_some() { return Err(format!( "Native layout frame {} cannot contain an echo payload", @@ -1197,7 +1345,7 @@ fn prepare_layout_job( .viewport_height .ok_or_else(|| format!("Native layout frame {} requires viewport-height", frame.key))?; let context = checked_layout_context( - document.as_ref(), + document, frame.key, viewport_width, frame.viewport_width_known, @@ -1223,7 +1371,7 @@ fn prepare_layout_job( ) })?; let base_context = checked_layout_context( - document.as_ref(), + document, frame.key, base_viewport_width, frame.base_viewport_width_known, @@ -1254,7 +1402,7 @@ fn prepare_layout_job( key: frame.key, delay_ms: frame.delay_ms, payload: JobPayload::Layout { - document: Arc::clone(document), + document: document.clone(), context, root_width, root_width_override: frame.root_width_override, @@ -1267,6 +1415,7 @@ fn prepare_layout_job( root_metadata: frame.root_metadata, document_base_revision, document_target_revision, + validation_resolver_lookups: layout::resolver_lookups(), }, }) } @@ -1287,6 +1436,7 @@ fn render_layout_payload( baseline_hit: false, base_renders: 0, target_renders: 0, + resolver_lookups: 0, }, JobPayload::Layout { document, @@ -1302,6 +1452,7 @@ fn render_layout_payload( root_metadata, document_base_revision, document_target_revision, + validation_resolver_lookups, } => { let identity = TapeIdentity { session_id, @@ -1331,8 +1482,20 @@ fn render_layout_payload( // job with neither result nor error tape, so the Elisp ready // watcher would poll forever. Convert panics into the same // error-tape channel ordinary layout failures use. - type LayoutRenderOutcome = Result<(Vec, LayoutTape, bool, u64, u64), String>; + type LayoutRenderOutcome = Result< + ( + Vec, + LayoutTape, + Vec, + bool, + u64, + u64, + ), + String, + >; + layout::reset_resolver_lookups(); let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { + let target_styles = document.styles()?; if let Some(base_context) = base_context { let base_identity = BaselineIdentity { context: base_context, @@ -1351,7 +1514,7 @@ fn render_layout_payload( &base_identity, ) && layout::style_registry_extends_exact_prefix( &baseline.styles, - &document.styles, + &target_styles, ) }) .cloned(); @@ -1360,12 +1523,12 @@ fn render_layout_payload( .layout_tape(context, root_width_override.then_some(root_width))?; let bytes = layout::encode_layout_tape( target.clone(), - &document.styles, + &target_styles, identity, output.root_metadata, output.max_bytes, )?; - return Ok((bytes, target, false, 0, 1)); + return Ok((bytes, target, target_styles, false, 0, 1)); } let (old, baseline_hit, base_renders) = if let Some(baseline) = base_hit { (baseline.tape.clone(), true, 0) @@ -1384,45 +1547,51 @@ fn render_layout_payload( let bytes = layout::encode_layout_patch_tape( old, target.clone(), - &document.styles, + &target_styles, identity, output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, baseline_hit, base_renders, 1)) + Ok((bytes, target, target_styles, baseline_hit, base_renders, 1)) } else { let target = document.layout_tape(context, root_width_override.then_some(root_width))?; let bytes = layout::encode_layout_tape( target.clone(), - &document.styles, + &target_styles, identity, output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, false, 0, 1)) + Ok((bytes, target, target_styles, false, 0, 1)) } })); + let resolver_lookups = + validation_resolver_lookups.saturating_add(layout::resolver_lookups()); match result { - Ok(Ok((bytes, tape, baseline_hit, base_renders, target_renders))) => RenderedJob { - bytes, - pending: Some(PendingBaseline { - confirmed_identity: pending_identity, - document: Arc::clone(&document), - document_revision: document_target_revision, - tape, - styles: document.styles.clone(), - }), - baseline_hit, - base_renders, - target_renders, - }, + Ok(Ok((bytes, tape, styles, baseline_hit, base_renders, target_renders))) => { + RenderedJob { + bytes, + pending: Some(PendingBaseline { + confirmed_identity: pending_identity, + document: document.clone(), + document_revision: document_target_revision, + tape, + styles, + }), + baseline_hit, + base_renders, + target_renders, + resolver_lookups, + } + } Ok(Err(error)) => RenderedJob { bytes: encode_error_tape(identity, &error, max_result_bytes), pending: None, baseline_hit: false, base_renders: 0, target_renders: 0, + resolver_lookups, }, Err(_) => RenderedJob { bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), @@ -1430,6 +1599,7 @@ fn render_layout_payload( baseline_hit: false, base_renders: 0, target_renders: 0, + resolver_lookups, }, } } @@ -1464,7 +1634,8 @@ fn render_proof(payload: &[u8]) -> Result, String> { if frame.delay_ms != 0 { return Err("Native proof render cannot contain delay-ms".to_owned()); } - let prepared = prepare_layout_job(&Arc::new(document), frame, 0, 1)?; + let document = LayoutSource::Full(Arc::new(document)); + let prepared = prepare_layout_job(&document, frame, 0, 1)?; let output = render_layout_payload( prepared.payload, SYNC_RENDER_SESSION_ID, @@ -1577,6 +1748,7 @@ fn worker_loop(shared: Arc) { } state.base_renders += output.base_renders; state.target_renders += output.target_renders; + state.document_resolver_lookups += output.resolver_lookups; state.results.insert( (job.generation, job.key), ResultEntry { @@ -1994,6 +2166,14 @@ mod tests { .into_bytes() } + fn identified_proof_layout_payload(frames: &str) -> Vec { + let mut payload: serde_json::Value = + serde_json::from_slice(&proof_layout_payload(frames)).unwrap(); + payload["document"]["root"]["node-id"] = serde_json::json!(1); + payload["document"]["root"]["node-revision"] = serde_json::json!(7); + serde_json::to_vec(&payload).unwrap() + } + fn replacement_layout_payload( document_base_revision: u64, document_target_revision: u64, @@ -2336,6 +2516,79 @@ mod tests { grandchild.stop(true); } + #[test] + fn nonzero_document_delta_renders_and_promotes_without_full_reparse() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + let first_tape = session.render_sync(1, &first).unwrap(); + assert!(!tape_patch_p(&first_tape)); + assert!(session.confirm(1, 1, 1).unwrap()); + + let delta = serde_json::to_vec(&serde_json::json!({ + "version": 1, + "document-base-revision": 1, + "document-target-revision": 2, + "document-delta": { + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": 7, + "target-revision": 21, + "slot-patches": [{ + "slot": 0, + "local": { + "content": {"lines": [{"clusters": [{ + "text": "y", "width": 8, "cjk": false, "space": false + }]}]} + } + }] + }] + }, + "frames": [{ + "key": 2, + "viewport-width": 80, + "viewport-height": 10, + "root-width": 80, + "patch": true, + "base-viewport-width": 80, + "base-viewport-height": 10, + "base-root-width": 80, + "runtime-revision": 1, + "context-hash": 77 + }] + })) + .unwrap(); + let delta_tape = session.render_sync(2, &delta).unwrap(); + assert!(tape_patch_p(&delta_tape)); + let stats = session.stats(); + assert_eq!(stats.document_parses, 1); + assert_eq!(stats.document_validations, 1); + assert_eq!(stats.document_full_nodes_parsed, 1); + assert_eq!(stats.document_delta_entries_parsed, 1); + assert_eq!(stats.document_delta_entries_validated, 1); + assert_eq!(stats.document_trie_path_nodes_copied, 17); + assert_eq!(stats.document_delta_input_bytes, delta.len() as u64); + assert!( + stats.document_resolver_lookups >= 3, + "target/base validation and target render lookups must all remain visible" + ); + assert!(session.confirm(2, 2, 2).unwrap()); + + let child = session.fork_confirmed().unwrap(); + let retained = retained_layout_payload( + 2, + r#"[{"key":3,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":2,"context-hash":77}]"#, + ); + assert!(!child.render_sync(1, &retained).unwrap().is_empty()); + session.stop(true); + child.stop(true); + } + #[test] fn retained_document_reuse_rejects_wrong_or_ambiguous_revision() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 3da1c0e..cc8e9cf 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -2372,6 +2372,14 @@ remain retained identities." session initial-state root)) (next (copy-tree root)) (next-child (car (ebox-tree-node-children next)))) + (let ((bootstrap-root + (plist-get (plist-get initial-package :document) :root))) + (should (= (plist-get root :node-id) + (plist-get bootstrap-root :node-id))) + (should (integerp (plist-get bootstrap-root :node-revision))) + ;; The Text is fused into its owner and therefore has no addressable + ;; child entry in the full wire document. + (should (eq :null (plist-get bootstrap-root :child)))) (setf (ebox-native-reflow-session-styles session) (plist-get initial-package :styles) (ebox-native-reflow-session-layout-package session) @@ -2567,6 +2575,434 @@ remain retained identities." (should (= (plist-get (car controls) :document-base-revision) 10)) (should (= (plist-get (car controls) :document-target-revision) 11))))) +(ert-deftest ebox-native-persistent-index-path-copies-without-changing-base () + "A retained index update shares the base and leaves its values immutable." + (require 'ebox-native-reflow) + (let* ((base (ebox-native-reflow--persistent-index-put nil 1 'one)) + (next (ebox-native-reflow--persistent-index-put base 17 'seventeen))) + (should (eq 'one (ebox-native-reflow--persistent-index-get base 1))) + (should-not (ebox-native-reflow--persistent-index-get base 17)) + (should (eq 'one (ebox-native-reflow--persistent-index-get next 1))) + (should (eq 'seventeen + (ebox-native-reflow--persistent-index-get next 17))) + (should-not (eq base next)))) + +(ert-deftest ebox-native-session-fork-shares-immutable-compiler-roots () + "Forking does not clone the retained fragment map or persistent indexes." + (require 'ebox-native-reflow) + (let* ((cache (make-hash-table :test 'equal)) + (index (ebox-native-reflow--persistent-index-put nil 1 'entry)) + (styles (vector 'style)) + (session + (ebox-native-reflow--make-session + :handle 'parent :generation 4 :styles styles + :layout-package 'package :layout-fragment-cache cache + :layout-fragment-index index :layout-fragment-revision 8 + :layout-style-index index :layout-property-template-index index))) + (cl-letf (((symbol-function 'ebox-native--module-fork-confirmed) + (lambda (_handle) 'child))) + (let ((fork (ebox-native-reflow-fork-session session))) + (should (eq cache + (ebox-native-reflow-session-layout-fragment-cache fork))) + (should (eq index + (ebox-native-reflow-session-layout-fragment-index fork))) + (should (eq styles (ebox-native-reflow-session-styles fork))) + (should (= 8 + (ebox-native-reflow-session-layout-fragment-revision + fork))))))) + +(ert-deftest ebox-native-failed-delta-keeps-fork-and-parent-indexes-unchanged () + "A rejected candidate cannot publish its path-copied compiler index." + (require 'ebox-native-reflow) + (let* ((cache (make-hash-table :test 'equal)) + (base-index (ebox-native-reflow--persistent-index-put nil 1 'old)) + (next-index + (ebox-native-reflow--persistent-index-put base-index 1 'new)) + (old (list :document '(:version 2) :document-revision 4 + :styles [] :property-templates [])) + (next (copy-sequence old)) + (parent + (ebox-native-reflow--make-session + :handle 'parent :layout-package old :styles [] + :layout-fragment-cache cache :layout-fragment-index base-index))) + (plist-put next :document-revision 5) + (plist-put next :document-delta + '(:style-base-count 0 :styles-append [] + :property-template-base-count 0 + :property-template-target-count 0 :entries [])) + (plist-put next :native-fragment-index next-index) + (cl-letf (((symbol-function 'ebox-native--module-fork-confirmed) + (lambda (_handle) 'child))) + (let ((fork (ebox-native-reflow-fork-session parent))) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) next)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (&rest _) (error "reject delta")))) + (should-error + (ebox-native-reflow-execute-session-sync + fork 'node '(:key 1 :viewport-width 80 :viewport-height 10) + nil 'state)) + (should (eq base-index + (ebox-native-reflow-session-layout-fragment-index fork))) + (should (eq base-index + (ebox-native-reflow-session-layout-fragment-index parent))) + (should (eq cache + (ebox-native-reflow-session-layout-fragment-cache fork)))))))) + +(ert-deftest ebox-native-accepted-delta-invalidates-stale-full-cache () + "A later full fallback cannot resurrect pre-delta legacy fragments." + (require 'ebox-native-reflow) + (let* ((cache (make-hash-table :test 'equal)) + (base-index (ebox-native-reflow--persistent-index-put nil 1 'old)) + (next-index + (ebox-native-reflow--persistent-index-put base-index 1 'new)) + (old (list :document '(:version 2) :document-revision 4 + :styles [] :property-templates [])) + (next (copy-sequence old)) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :layout-package old :styles [] + :layout-fragment-cache cache :layout-fragment-index base-index))) + (puthash 1 'stale-fragment cache) + (plist-put next :document-revision 5) + (plist-put next :document-delta + '(:style-base-count 0 :styles-append [] + :property-template-base-count 0 + :property-template-target-count 0 :entries [])) + (plist-put next :native-fragment-index next-index) + (plist-put next :native-fragment-revision 9) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) next)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (&rest _) 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (ebox-native-reflow-execute-session-sync + session 'node '(:key 1 :viewport-width 80 :viewport-height 10) + nil 'state) + (should-not + (ebox-native-reflow-session-layout-fragment-cache session)) + (should (eq next-index + (ebox-native-reflow-session-layout-fragment-index session))) + (should (= 9 + (ebox-native-reflow-session-layout-fragment-revision + session))) + ;; Model the next unsupported B update. The current source contains A's + ;; accepted value while the persistent index is deliberately stale; the + ;; invalidated legacy cache forces a complete compile from current state. + (let* ((input + (ebox-test-box (ebox-test-text "A") :bgcolor "#00ff00")) + (root (ebox-test-root input)) + (_ids (ebox--runtime-node-ids root)) + (full + (ebox-native-reflow--compile-retained-layout-package-full + session + (list :native-node-postorder + (ebox-native-reflow--retained-layout-postorder root) + :native-topology-stable-p nil + :source-index (ebox-test-source-index input)) + root)) + (document-root (plist-get (plist-get full :document) :root)) + (style-id (plist-get document-root :background-style))) + (should + (equal '(:background "#00ff00") + (plist-get (aref (plist-get full :styles) style-id) + :face))))))) + +(ert-deftest ebox-native-node-delta-is-local-and-bumps-ancestors () + "A local change patches one owner and only revises its retained ancestor." + (require 'ebox-native-reflow) + (let* ((leaf-fragment + '(:type "box" :background-style :null :child :null + :node-id 2 :node-revision 3)) + (root-fragment + (list :type "box" :background-style :null :child leaf-fragment + :node-id 1 :node-revision 4)) + (leaf-entry (list :fragment leaf-fragment :revision 3)) + (root-entry (list :fragment root-fragment :revision 4)) + (index (ebox-native-reflow--persistent-index-put nil 1 root-entry)) + (_index (setq index + (ebox-native-reflow--persistent-index-put + index 2 leaf-entry))) + (style-index + (ebox-native-reflow--persistent-index-put + nil '(:mode add :face (:background "red")) 0)) + (package + (list :document '(:version 2) :document-revision 7 + :styles [] :property-templates [])) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :styles [] :layout-package package + :layout-fragment-index index :layout-fragment-revision 4 + :layout-style-index style-index)) + (nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (state + (list :node-table nodes :parent-table parents + :native-topology-stable-p t + :native-touched-node-ids '(2 1) + :native-local-dirty-entries + '((:node-id 2 :dirty-kind paint + :changed-keys (:background-color)))))) + (puthash 1 '(:node-id 1) nodes) + (puthash 2 '(:node-id 2) nodes) + (puthash 2 1 parents) + (cl-letf (((symbol-function 'ebox--current-display-signature) + (lambda () 'display)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (_node _old) + (vector + '(:type "box" :background-style 0 :child :null))))) + (let* ((next + (ebox-native-reflow--compile-retained-layout-delta + session state 'root)) + (delta (plist-get next :document-delta)) + (entries (plist-get delta :entries)) + (leaf (aref entries 0)) + (root (aref entries 1))) + (should (= 8 (plist-get next :document-revision))) + (should (= 0 (plist-get delta :style-base-count))) + (should (= 2 (length entries))) + (should (equal [(:slot 0 :local (:background-style 0))] + (plist-get leaf :slot-patches))) + (should-not (plist-member root :slot-patches)) + (should (= 5 (plist-get leaf :target-revision))) + (should (= 6 (plist-get root :target-revision))))))) + +(ert-deftest ebox-native-node-delta-deduplicates-multiple-leaf-closures () + "Multiple changed leaves produce one entry each and one shared ancestor." + (require 'ebox-native-reflow) + (let ((index nil) + (nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal))) + (dolist (pair '((1 . 3) (2 . 1) (3 . 2))) + (setq index + (ebox-native-reflow--persistent-index-put + index (car pair) + (list :fragment + '(:type "box" :background-style :null :child :null) + :revision (cdr pair)))) + (puthash (car pair) (list :node-id (car pair)) nodes)) + (puthash 2 1 parents) + (puthash 3 1 parents) + (let* ((package (list :document '(:version 2) :document-revision 2 + :styles [] :property-templates [])) + (session + (ebox-native-reflow--make-session + :handle 'test :layout-package package + :layout-fragment-index index :layout-fragment-revision 3)) + (state + (list :node-table nodes :parent-table parents + :native-topology-stable-p t + :native-touched-node-ids '(2 1 3 1) + :native-local-dirty-entries + '((:node-id 2 :changed-keys (:background-color)) + (:node-id 3 :changed-keys (:background-color)))))) + (cl-letf (((symbol-function 'ebox--current-display-signature) + (lambda () 'display)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (node _old) + (vector + (list :type "box" :background-style + (plist-get node :node-id) :child :null))))) + (let* ((next + (ebox-native-reflow--compile-retained-layout-delta + session state 'root)) + (entries + (plist-get (plist-get next :document-delta) :entries))) + (should (= 3 (length entries))) + (should (equal '(2 1 3) + (mapcar (lambda (entry) + (plist-get entry :node-id)) + (append entries nil)))) + (should (plist-member (aref entries 0) :slot-patches)) + (should-not (plist-member (aref entries 1) :slot-patches)) + (should (plist-member (aref entries 2) :slot-patches))))))) + +(ert-deftest ebox-native-fused-text-delta-resolves-to-box-owner () + "A fused text id addresses its containing Box rather than a hidden node." + (require 'ebox-native-reflow) + (let ((nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (index (ebox-native-reflow--persistent-index-put nil 10 'owner))) + (puthash 10 + (list :node-id 10 :ebox-kind 'box + :ebox-layout-config + (ebox-normal-layout-create)) + nodes) + (puthash 11 '(:node-id 11 :ebox-kind text) nodes) + (puthash 11 10 parents) + (should (= 10 + (ebox-native-reflow--delta-owner-id + (list :node-table nodes :parent-table parents) 11 index))))) + +(ert-deftest ebox-native-flex-item-metadata-change-uses-full-input () + "N1 does not misrepresent Flex item metadata as a local scalar patch." + (require 'ebox-native-reflow) + (let ((session + (ebox-native-reflow--make-session + :handle 'test :layout-package 'old + :layout-fragment-index + (ebox-native-reflow--persistent-index-put + nil 1 '(:fragment (:type "box" :child :null) :revision 1)))) + (state + '(:native-topology-stable-p t :native-touched-node-ids (1) + :native-local-dirty-entries + ((:node-id 1 :dirty-kind geometry + :changed-keys (:flex-grow))))) + full-called) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package-full) + (lambda (&rest _) + (setq full-called t) + 'full)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (&rest _) + (ert-fail "Flex item metadata reached local delta")))) + (should (eq 'full + (ebox-native-reflow--compile-retained-layout-package + session state 'node))) + (should full-called)))) + +(ert-deftest ebox-native-flex-child-content-change-uses-full-input () + "Child content may alter retained Flex edge measurement in N1." + (require 'ebox-native-reflow) + (let ((nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (index + (ebox-native-reflow--persistent-index-put + nil 2 '(:fragment (:type "box" :content [] :child :null) + :revision 3))) + full-called) + (puthash 1 '(:node-id 1 :ebox-type flex) nodes) + (puthash 2 + (list :node-id 2 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-normal-layout-create)) + nodes) + (puthash 3 '(:node-id 3 :ebox-type box :ebox-kind text) nodes) + (puthash 2 1 parents) + (puthash 3 2 parents) + (let ((session + (ebox-native-reflow--make-session + :handle 'test :layout-package 'old + :layout-fragment-index index)) + (state + (list :node-table nodes :parent-table parents + :native-topology-stable-p t + :native-touched-node-ids '(3 2 1) + :native-local-dirty-entries + '((:node-id 3 :dirty-kind content + :changed-keys (:content)))))) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package-full) + (lambda (&rest _) + (setq full-called t) + 'full)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (&rest _) + (ert-fail "Flex child content reached local delta")))) + (should (eq 'full + (ebox-native-reflow--compile-retained-layout-package + session state 'node))) + (should full-called))))) + +(ert-deftest ebox-native-default-axis-child-change-keeps-local-input () + "Default Row/Column edges carry no derived Flex item metadata." + (require 'ebox-native-reflow) + (let ((nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal))) + (puthash 1 + (list :node-id 1 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-column-layout-create)) + nodes) + (puthash 2 + (list :node-id 2 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-normal-layout-create)) + nodes) + (puthash 3 '(:node-id 3 :ebox-type box :ebox-kind text) nodes) + (puthash 2 1 parents) + (puthash 3 2 parents) + (should-not + (ebox-native-reflow--delta-flex-edge-change-p + (list :node-table nodes :parent-table parents + :native-local-dirty-entries + '((:node-id 3 :dirty-kind geometry + :changed-keys (:content)))))))) + +(ert-deftest ebox-native-surface-overrides-carry-local-dirty-entries () + "The incremental producer preserves exact local dirtiness to native input." + (let* ((dirty '((:node-id 7 :dirty-kind paint + :changed-keys (:background-color)))) + (candidate (list :runtime-revision 3 + :native-topology-stable-p t + :native-touched-node-ids '(7 1) + :native-local-dirty-entries dirty)) + (overrides + (ebox-incremental--surface-state-overrides + nil '(:display-signature display) candidate 'native-frame))) + (should (equal dirty + (plist-get overrides :native-local-dirty-entries))))) + +(ert-deftest ebox-native-wide-owner-local-compile-does-not-enumerate-children () + "A scalar slot update never walks a stable owner's unchanged children." + (require 'ebox-native-reflow) + (let ((node (list :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-flex-layout-create))) + (old '(:type "box" :child (:type "flex" :items [a b c])))) + (cl-letf (((symbol-function 'ebox-tree-node-children) + (lambda (&rest _) + (ert-fail "stable delta enumerated unchanged children"))) + ((symbol-function 'ebox-native-reflow--compile-flex-inner) + (lambda (_props items &rest _) + (should-not items) + '(:type "flex" :items []))) + ((symbol-function 'ebox-native-reflow--compile-box) + (lambda (_box child &rest _) + (list :type "box" :child child)))) + (let ((slots (ebox-native-reflow--compile-delta-slots node old))) + (should (= 2 (length slots))) + (should (equal [] (plist-get (aref slots 1) :items))))))) + +(ert-deftest ebox-native-retained-sync-sends-document-delta-without-document () + "A supported retained compile sends only its node delta and revisions." + (require 'ebox-native-reflow) + (let* ((old (list :document '(:version 2) :document-revision 4 + :styles [] :property-templates [])) + (delta '(:style-base-count 0 :styles-append [] + :property-template-base-count 0 + :property-template-target-count 0 :entries [])) + (next (copy-sequence old)) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :styles [] :layout-package old)) + control) + (plist-put next :document-revision 5) + (plist-put next :document-delta delta) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) next)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (_handle _generation payload) + (setq control + (json-parse-string payload :object-type 'plist + :array-type 'array)) + 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 80 :viewport-height 10) nil 'state) + (should-not (plist-member control :document)) + (should (equal delta (plist-get control :document-delta))) + (should (= 4 (plist-get control :document-base-revision))) + (should (= 5 (plist-get control :document-target-revision))) + (should-not + (plist-member + (ebox-native-reflow-session-layout-package session) + :document-delta))))) + (provide 'ebox-commit-tests) ;;; ebox-commit-tests.el ends here diff --git a/tests/ebox-m0a-inventory-fixture.el b/tests/ebox-m0a-inventory-fixture.el index c59c8d3..3cf8fa9 100644 --- a/tests/ebox-m0a-inventory-fixture.el +++ b/tests/ebox-m0a-inventory-fixture.el @@ -411,6 +411,55 @@ :authority ebox :lifetime candidate-until-final-accept :rollback private-session-discard :rebuild native-frame-stage :cleanup confirm-or-rollback) + (:id candidate/native-local-dirty-entries + :storage (:state-key :native-local-dirty-entries) + :proposed-category generation-fact :owner ebox-native-commit + :authority ebox :lifetime candidate-until-native-input + :rollback candidate-discard :rebuild prepared-dirty-set + :cleanup native-frame-consume-or-downgrade) + (:id native-session/layout-fragment-index + :storage (:struct-slot ebox-native-reflow-session layout-fragment-index) + :proposed-category generation-bound-mutable :owner ebox-native-reflow + :authority ebox :lifetime private-native-session + :rollback private-session-discard :rebuild full-bootstrap-or-path-copy + :cleanup native-session-release) + (:id native-session/layout-style-index + :storage (:struct-slot ebox-native-reflow-session layout-style-index) + :proposed-category generation-bound-mutable :owner ebox-native-reflow + :authority ebox :lifetime private-native-session + :rollback private-session-discard :rebuild full-bootstrap-or-style-append + :cleanup native-session-release) + (:id native-session/layout-property-template-index + :storage + (:struct-slot ebox-native-reflow-session layout-property-template-index) + :proposed-category generation-bound-mutable :owner ebox-native-reflow + :authority ebox :lifetime private-native-session + :rollback private-session-discard :rebuild full-bootstrap + :cleanup native-session-release) + (:id native-compile/style-index + :storage (:global ebox-native-reflow--compile-style-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime dynamic-native-compile + :rollback dynamic-binding-unwind :rebuild retained-session-root + :cleanup dynamic-binding-unwind) + (:id native-compile/property-template-index + :storage (:global ebox-native-reflow--compile-property-template-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime dynamic-native-compile + :rollback dynamic-binding-unwind :rebuild retained-session-root + :cleanup dynamic-binding-unwind) + (:id native-package/fragment-index + :storage (:state-key :native-fragment-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime native-call-until-success + :rollback package-discard :rebuild path-copied-session-root + :cleanup strip-from-confirmed-package) + (:id native-package/style-index + :storage (:state-key :native-style-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime native-call-until-success + :rollback package-discard :rebuild style-append-path-copy + :cleanup strip-from-confirmed-package) (:id process/scroll-global-state :storage (:global ebox--scroll-global-state) :proposed-category generation-bound-mutable :owner ebox-incremental diff --git a/tests/ebox-state-contract-tests.el b/tests/ebox-state-contract-tests.el index a9b777a..1f73910 100644 --- a/tests/ebox-state-contract-tests.el +++ b/tests/ebox-state-contract-tests.el @@ -466,18 +466,20 @@ exclusion freshness, but only retained/container evidence is coverage-gated." 'generation-bound-mutable-authority)))) (ert-deftest ebox-state-contract-excludes-native-compile-scratch-from-authority () - "Compile-local template ids are not mislabeled as native session authority." - (should-not - (memq 'ebox-native-reflow--compile-property-template-ids + "Compile-local indexes are not mislabeled as native session authority." + (let ((authority-storage (plist-get (ebox-state-contract-record 'native-runtime-authority) :storage))) - (let ((exclusion - (cl-find 'ebox-native-reflow--compile-property-template-ids - ebox-state-contract-source-scan-exclusions - :key (lambda (entry) (plist-get entry :symbol))))) - (should exclusion) - (should (eq (plist-get exclusion :reason) - 'dynamically-bound-compile-local-scratch)))) + (dolist (symbol '(ebox-native-reflow--compile-property-template-ids + ebox-native-reflow--compile-property-template-index + ebox-native-reflow--compile-style-index)) + (should-not (memq symbol authority-storage)) + (let ((exclusion + (cl-find symbol ebox-state-contract-source-scan-exclusions + :key (lambda (entry) (plist-get entry :symbol))))) + (should exclusion) + (should (eq (plist-get exclusion :reason) + 'dynamically-bound-compile-local-scratch)))))) (ert-deftest ebox-state-contract-tp-custody-is-opaque () "The inventory separates current whole-state custody from its M2a target." diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index b868a0e..40eb1a8 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -1656,17 +1656,25 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (plist-get (tp-surface-client-state surface) :native-sync-session)) (should (eq (plist-get report :projection-kind) 'native-frame)) - ;; A style-table change cannot consume the prior confirmed - ;; document and therefore remains a truthful full frame. - (should (eq (plist-get report :native-frame-kind) 'full)) + ;; A root paint delta and appended styles preserve the old + ;; registry prefix and publish against the confirmed tape. + (should (eq (plist-get report :native-frame-kind) 'patch)) (should next-session) (should-not (eq next-session winning-session)) (should (ebox-native-reflow-session-released-p winning-session)) (should-not (ebox-native-reflow-session-released-p next-session)) + (should + (equal-including-properties + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))) + (ebox-surface-test--render-runtime + (tp-surface-client-state surface)))) (let ((stats (ebox-native-reflow-stats next-session))) - (should (= (plist-get stats :document-parses) 1)) - (should (= (plist-get stats :document-validations) 1)) + (should (= (plist-get stats :document-parses) 0)) + (should (= (plist-get stats :document-validations) 0)) + (should (= (plist-get stats :document-delta-entries-parsed) 1)) + (should (= (plist-get stats :document-delta-entries-validated) 1)) (should (= (plist-get stats :document-reuses) 0)))))) (when (buffer-live-p buffer) (kill-buffer buffer)) @@ -1725,6 +1733,108 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-native-node-delta-survives-viewport-and-repeated-commits () + "Real local commits preserve earlier edits across viewport document reuse." + (skip-unless (ebox-native-reflow-layout-ready-p)) + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-native-delta-history*")) + (ebox-viewport-width 320) + (ebox-viewport-height 40) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (unwind-protect + (cl-labels + ((item + (index text) + (ebox-test-box + :key index :source-identity index :width '(8) :height 1 + (ebox-test-text text :key (+ 100 index) + :source-identity (+ 100 index)))) + (source + (count) + (apply #'ebox-test-column + (append (list :key 'delta-history + :source-identity 'delta-history + :width '(viewport)) + (cl-loop for index below count + collect (item index "A"))))) + (ids + (state) + (let (result) + (ebox-surface-test--walk-runtime + (plist-get state :root-node) + (lambda (node) (push (plist-get node :node-id) result))) + (nreverse result)))) + (cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p) + (lambda () nil))) + (ebox-render-to-buffer buffer (source 15))) + (ebox-commit buffer (source 16)) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (baseline-ids (ids (tp-surface-client-state surface))) + (original-send + (symbol-function 'ebox-native--module-render-session-frame)) + (expected-texts (make-vector 16 "A")) + previous-document-revision events) + (should (plist-get (tp-surface-client-state surface) + :native-sync-confirmed-p)) + (cl-letf + (((symbol-function 'ebox-native--module-render-session-frame) + (lambda (handle generation control) + (push (json-parse-string control :object-type 'plist + :array-type 'array) + events) + (funcall original-send handle generation control)))) + (dolist (step '((4 "B") viewport (12 "C") (4 "D"))) + (setq events nil) + (if (eq step 'viewport) + (progn + (setq ebox-viewport-width 360) + (ebox-rerender-buffer-with-context buffer 360 40)) + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate (car step) (item (car step) (cadr step))) + (ebox-commit buffer candidate) + (aset expected-texts (car step) (cadr step)))) + (ert-info ((format "Native delta history step %S" step)) + (should (= 1 (length events)))) + (let* ((control (car events)) + (base (plist-get control :document-base-revision)) + (target (plist-get control :document-target-revision)) + (state (tp-surface-client-state surface)) + (session (plist-get state :native-sync-session))) + (should-not (plist-get control :document)) + (when previous-document-revision + (should (= base previous-document-revision))) + (if (eq step 'viewport) + (progn + (should-not (plist-get control :document-delta)) + (should (= base target))) + (should (plist-get control :document-delta)) + (should (= (1+ base) target))) + (setq previous-document-revision target) + (should (equal baseline-ids (ids state))) + (should + (equal (mapconcat #'identity expected-texts "") + (with-current-buffer buffer + (replace-regexp-in-string + "[[:space:]]" "" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (should (plist-get state :native-sync-confirmed-p)) + (should-not (plist-get state :native-sync-pending)) + (should-not + (plist-get + (ebox-native-reflow-session-layout-package session) + :document-delta)) + (should + (equal-including-properties + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))) + (ebox-surface-test--render-runtime state)))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-viewport-reflow-retains-final-sized-flex-child-fragments () "Viewport reflow should reuse final-sized Flex child fragments exactly." (ebox-surface-test--reset-render-state)