Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 97 additions & 2 deletions dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"fmt"
"hash/fnv"
"image"
"io"
"sort"
"strconv"
"time"

Expand Down Expand Up @@ -44,12 +46,105 @@ type layoutMetrics struct {
sm css.StyleMap
vpW, vpH int
scale float64 // device px per CSS px; 0 means 1 (see scaled)

// Forced synchronous layout (see liveMetrics / Refresh): the document, the
// signature of the DOM the current geometry was laid out from, and how to
// lay it out again. relayout is nil for a static snapshot.
root *dom.Node
sig uint64
relayout func() (*layout.Box, css.StyleMap)
}

func newLayoutMetrics(box *layout.Box, sm css.StyleMap, vpW, vpH int) *layoutMetrics {
return &layoutMetrics{idx: layout.BuildIndex(box), sm: sm, vpW: vpW, vpH: vpH}
}

// liveMetrics is the Metrics a script session reads: the pass rp just laid
// out, plus the means to lay the document out again when a script has
// mutated it since — so geometry read right after a mutation (Marp's fitting
// headings wrap a heading's content in a new span and measure it at once) is
// the geometry of the mutated DOM, as CSSOM View's getBoundingClientRect /
// offset* / client* / scroll* require. The re-layout is private to the
// metrics: the settle loop still decides what the render's own pass is.
func (e *Engine) liveMetrics(doc *Document, rp *renderPass, fonts *paint.Fonts, vpW, vpH int) *layoutMetrics {
m := newLayoutMetrics(rp.box, rp.sm, vpW, vpH).scaled(e.devScale())
m.root, m.sig = doc.Root, treeSignature(doc.Root)
sheets, imgSize := rp.sheets, rp.imgSize
m.relayout = func() (*layout.Box, css.StyleMap) {
sm := e.cascade(doc.Root, vpW, sheets, nil)
box, h := e.layoutDoc(doc.Root, sm, vpW, vpH, fonts, imgSize)
sm, box, _ = e.layoutWithContainers(doc.Root, sheets, vpW, vpH, fonts, imgSize, sm, box, h)
return box, sm
}
return m
}

// Refresh implements js.Refresher: when the DOM differs from the one the
// current geometry describes, lay it out again first.
func (m *layoutMetrics) Refresh() {
if m.relayout == nil {
return
}
sig := treeSignature(m.root)
if sig == m.sig {
return
}
box, sm := m.relayout()
m.idx, m.sm, m.sig = layout.BuildIndex(box), sm, sig
}

// treeSignature hashes everything layout depends on in the DOM — structure,
// tags, attributes (class and style included) and text — without
// serialising it, so the check before every geometry read stays cheap. Each
// node's child count is folded in, so moving a node to another parent
// changes it even when the markup reads the same.
func treeSignature(root *dom.Node) uint64 {
h := fnv.New64a()
var buf [8]byte
num := func(v uint64) {
for i := range buf {
buf[i] = byte(v >> (8 * i))
}
_, _ = h.Write(buf[:])
}
str := func(s string) {
num(uint64(len(s)))
_, _ = io.WriteString(h, s)
}
var walk func(n *dom.Node, depth int)
walk = func(n *dom.Node, depth int) {
if n == nil || depth > 512 {
return
}
num(uint64(n.Type))
str(n.Tag)
str(n.Text)
num(uint64(len(n.Attr)))
if len(n.Attr) > 0 {
keys := make([]string, 0, len(n.Attr))
for k := range n.Attr {
keys = append(keys, k)
}
sort.Strings(keys) // map order is random; the hash must not be
for _, k := range keys {
str(k)
str(n.Attr[k])
}
}
num(uint64(len(n.Children)))
for _, c := range n.Children {
walk(c, depth+1)
}
if n.Shadow != nil {
for _, c := range n.Shadow.Children {
walk(c, depth+1)
}
}
}
walk(root, 0)
return h.Sum64()
}

// Rect returns n's used border-box rect (document coords).
func (m *layoutMetrics) Rect(n *dom.Node) (x, y, w, h float64, ok bool) {
r, ok := m.idx[n]
Expand Down Expand Up @@ -296,7 +391,7 @@ func domSignature(root *dom.Node) uint64 {
func (e *Engine) settle(ctx context.Context, doc *Document, vpW, vpH int, fonts *paint.Fonts, rp *renderPass, initialLayout time.Duration, onStage func(stage string, rp *renderPass)) *js.Session {
sess := js.Begin(doc.Root, e.jsOptions(ctx, doc, vpW, vpH))

sess.SetMetrics(newLayoutMetrics(rp.box, rp.sm, vpW, vpH).scaled(e.devScale()))
sess.SetMetrics(e.liveMetrics(doc, rp, fonts, vpW, vpH))
// Signature of the DOM the INITIAL layout was computed from (client-js already
// applied), captured before the scripts run so a mutation is detectable.
sig := domSignature(doc.Root)
Expand Down Expand Up @@ -408,7 +503,7 @@ func (e *Engine) settle(ctx context.Context, doc *Document, vpW, vpH int, fonts
onStage("settle", rp)
}

sess.SetMetrics(newLayoutMetrics(rp.box, rp.sm, vpW, vpH).scaled(e.devScale()))
sess.SetMetrics(e.liveMetrics(doc, rp, fonts, vpW, vpH))
// Run any <script> injected since the last pass (a ResourceLoader chain).
// If none ran, a layout change alone triggers no further JS, so the state
// just computed is final.
Expand Down
111 changes: 111 additions & 0 deletions forced_layout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) the go-webengine/engine authors.
// SPDX-License-Identifier: BSD-3-Clause

package engine

import (
"context"
"image"
"strconv"
"strings"
"testing"

"github.com/go-webengine/engine/css"
"github.com/go-webengine/engine/dom"
"github.com/go-webengine/engine/layout"
)

// scriptTitle renders page with scripts enabled and returns document.title.
func scriptTitle(t *testing.T, page string) string {
t.Helper()
img, info, err := New().RenderHTML(context.Background(), page, "https://demo.test/", image.Rect(0, 0, 400, 200))
if err != nil || img == nil {
t.Fatalf("render: %v", err)
}
return info.Title
}

// TestForcedSynchronousLayout: geometry read after a DOM mutation reflects
// the mutation (CSSOM View: getBoundingClientRect, offset*, client* and
// scroll* answer from an up-to-date layout — the "forced synchronous
// layout" every engine performs). Marp's fitting headings wrap a heading's
// children in a new <span> and measure it straight away.
func TestForcedSynchronousLayout(t *testing.T) {
static := scriptTitle(t, `<!DOCTYPE html><html><head><style>body{margin:0}h1{font-size:32px}</style></head><body>`+
`<h1><span id="s">Short</span></h1><script>document.title=String(document.getElementById('s').getBoundingClientRect().width)</script></body></html>`)
dynamic := scriptTitle(t, `<!DOCTYPE html><html><head><style>body{margin:0}h1{font-size:32px}</style></head><body>`+
`<h1>Short</h1><script>var h=document.querySelector('h1'),s=document.createElement('span');`+
`while(h.firstChild)s.appendChild(h.firstChild);h.appendChild(s);`+
`document.title=String(s.getBoundingClientRect().width)+'|'+s.offsetWidth</script></body></html>`)
sw, err := strconv.ParseFloat(static, 64)
if err != nil || sw <= 0 {
t.Fatalf("static span width %q", static)
}
parts := strings.Split(dynamic, "|")
if parts[0] != static {
t.Errorf("span created by script measures %q, want %q (same as the static span)", parts[0], static)
}
if parts[1] == "0" {
t.Errorf("offsetWidth of the script-created span = 0")
}

// A style change is a mutation too, and so is text.
got := scriptTitle(t, `<!DOCTYPE html><html><head><style>body{margin:0}</style></head><body>`+
`<div id="d" style="width:100px;height:10px"></div><p id="p" style="display:inline-block">a</p><script>var d=document.getElementById('d'),p=document.getElementById('p');`+
`var w0=d.offsetWidth;d.style.width='150px';var w1=d.offsetWidth;d.setAttribute('style','width:30px;height:10px');var w2=d.getBoundingClientRect().width;`+
`var p0=p.offsetWidth;p.textContent='aaaaaaaaaa';var p1=p.offsetWidth;`+
`document.title=w0+','+w1+','+w2+','+(p1>p0)</script></body></html>`)
if got != "100,150,30,true" {
t.Errorf("widths after style/text mutations = %q, want 100,150,30,true", got)
}
}

// TestTreeSignature: stable for an unchanged DOM (so a read without a
// mutation never re-lays out), sensitive to attributes, text and moves.
func TestTreeSignature(t *testing.T) {
root, err := dom.Parse(`<html><body><h1 class="a" id="h">Short</h1><p>x</p></body></html>`)
if err != nil {
t.Fatal(err)
}
s0 := treeSignature(root)
for i := 0; i < 5; i++ {
if treeSignature(root) != s0 {
t.Fatal("signature not stable for an unchanged tree (attribute map order?)")
}
}
h := dom.Find(root, "h1")
p := dom.Find(root, "p")
h.Attr["class"] = "b"
s1 := treeSignature(root)
if s1 == s0 {
t.Error("attribute change not detected")
}
p.Children[0].Text = "y"
s2 := treeSignature(root)
if s2 == s1 {
t.Error("text change not detected")
}
span := dom.NewElement("span")
txt := h.Children[0]
dom.RemoveChild(h, txt)
dom.AppendChild(span, txt)
dom.AppendChild(h, span)
if treeSignature(root) == s2 {
t.Error("wrapping the heading's text in a span not detected")
}

m := &layoutMetrics{root: root, sig: treeSignature(root)}
calls := 0
m.relayout = func() (*layout.Box, css.StyleMap) { calls++; return nil, nil }
m.Refresh()
if calls != 0 {
t.Error("re-laid out an unchanged DOM")
}
h.Attr["class"] = "c"
m.Refresh()
m.Refresh()
if calls != 1 {
t.Errorf("relayout calls = %d, want 1 (once per change)", calls)
}
(&layoutMetrics{}).Refresh() // a static snapshot: no-op
}
2 changes: 1 addition & 1 deletion interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ func (d *LiveDocument) resettle(ctx context.Context) {
d.rp.sm, d.rp.box, d.rp.height = newSm, newBox, newHeight
relaidOut = true

d.sess.SetMetrics(newLayoutMetrics(d.rp.box, d.rp.sm, d.vpW, d.vpH).scaled(d.e.devScale()))
d.sess.SetMetrics(d.e.liveMetrics(d.doc, d.rp, d.fonts, d.vpW, d.vpH))
// A newly-inserted <script> (rare from a plain interaction, but a
// click handler could add one) gets the same chance settle gives it;
// no new script means this mutation is fully reflected already.
Expand Down
33 changes: 33 additions & 0 deletions js/dynamic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,39 @@ func TestSessionGeometryReadback(t *testing.T) {
)
}

// refreshFakeMetrics adds Refresher to fakeMetrics.
type refreshFakeMetrics struct {
fakeMetrics
refreshes int
}

func (m *refreshFakeMetrics) Refresh() { m.refreshes++ }

// TestSessionRefreshBeforeGeometryReads: every geometry read first calls
// Refresh — the forced synchronous layout.
func TestSessionRefreshBeforeGeometryReads(t *testing.T) {
root, err := dom.Parse(`<html><body><div id="d"></div><script>
var d=document.getElementById('d');
console.log('w='+d.offsetWidth+','+d.getBoundingClientRect().width);
getComputedStyle(d).width;
</script></body></html>`)
if err != nil {
t.Fatal(err)
}
d := dom.Find(root, "div")
m := &refreshFakeMetrics{fakeMetrics: fakeMetrics{rect: map[*dom.Node][4]float64{d: {0, 0, 64, 34}}}}
var logs []string
sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second,
Log: func(l string) { logs = append(logs, l) }})
sess.SetMetrics(m)
sess.RunInitial()
sess.Close()
mustHaveJS(t, logs, "w=64,64")
if m.refreshes != 3 {
t.Errorf("Refresh called %d times, want once per geometry read (3)", m.refreshes)
}
}

// TestSessionRunPending proves a <script> injected by a running script is picked
// up and executed by the next RunPending, in the same runtime — and only once.
func TestSessionRunPending(t *testing.T) {
Expand Down
10 changes: 10 additions & 0 deletions js/geometry.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@ func (b *binder) rectOf(n *dom.Node) (x, y, w, h float64, ok bool) {
if b.metrics == nil {
return 0, 0, 0, 0, false
}
b.refreshLayout()
return b.metrics.Rect(n)
}

// refreshLayout asks the Metrics source to catch up with any DOM mutation
// before geometry is read (see Refresher).
func (b *binder) refreshLayout() {
if r, ok := b.metrics.(Refresher); ok {
r.Refresh()
}
}

// boundingRect builds a DOMRect object for n from its laid-out geometry. An
// unlaid-out node yields an all-zero rect (matching a display:none element).
func (b *binder) boundingRect(n *dom.Node) goja.Value {
Expand Down Expand Up @@ -92,6 +101,7 @@ func (c *computedStyleObj) prop(name string) string {
if c.b.metrics == nil {
return styleProp(c.n, camelToKebab(name))
}
c.b.refreshLayout()
if v, ok := c.b.metrics.Computed(c.n, camelToKebab(name)); ok {
return v
}
Expand Down
9 changes: 9 additions & 0 deletions js/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ type Metrics interface {
Computed(n *dom.Node, prop string) (string, bool)
}

// Refresher is an optional Metrics extension: Refresh brings the geometry up
// to date with the DOM before it is read, re-laying the page out if a script
// mutated it since the last layout — the "forced synchronous layout" CSSOM
// View's getBoundingClientRect / offset* / client* / scroll* /
// getComputedStyle imply. The binder calls it before every geometry read.
type Refresher interface {
Refresh()
}

// Session is a live page-script execution bound to one DOM tree and one goja
// runtime. Unlike the one-shot Run, it stays resident across layout passes: the
// engine lays the page out, feeds the geometry in via SetMetrics, runs scripts
Expand Down
Loading