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
9 changes: 7 additions & 2 deletions dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,13 @@ func domSignature(root *dom.Node) uint64 {
// onStage (when non-nil, from a progressive render) is called with "settle"
// after each pass that re-laid-out the document; the caller dedups against the
// previous emitted frame's geometry.
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)) {
//
// settle returns the live Session rather than closing it: a one-shot render
// (renderCoreStaged) closes it immediately after, byte-identical to before;
// Engine.Open keeps it alive instead, across separate later interactions, via
// LiveDocument (interactive.go) — the only reason the return value exists.
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))
defer sess.Close()

sess.SetMetrics(newLayoutMetrics(rp.box, rp.sm, vpW, vpH))
// Signature of the DOM the INITIAL layout was computed from (client-js already
Expand Down Expand Up @@ -386,6 +390,7 @@ func (e *Engine) settle(ctx context.Context, doc *Document, vpW, vpH int, fonts
rp.box, rp.height = layout.LayoutDocument(doc.Root, rp.sm, float64(vpW), fonts, rp.imgSize)
rp.sm, rp.box, rp.height = layoutWithContainers(doc.Root, rp.sheets, float64(vpW), fonts, rp.imgSize, rp.sm, rp.box, rp.height)
}
return sess
}

// reskin walks a PRESERVED box tree (geometry already final and not to be
Expand Down
4 changes: 2 additions & 2 deletions dynamic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ func TestSettleWipeGuardReskinsWithNewStyle(t *testing.T) {
rp.imgSize = map[*dom.Node][2]float64{}
rp.box, rp.height = layout.LayoutDocument(doc.Root, rp.sm, 400, fonts, rp.imgSize)
e := New()
e.settle(context.Background(), doc, 400, 200, fonts, rp, time.Millisecond, nil)
e.settle(context.Background(), doc, 400, 200, fonts, rp, time.Millisecond, nil).Close()

if rp.height < emptyRenderHeight {
t.Fatalf("settle rp.height = %v, want the pre-wipe geometry kept", rp.height)
Expand Down Expand Up @@ -380,7 +380,7 @@ func TestSettleBudgetGuard(t *testing.T) {
defer cancel()
// A huge claimed initial-layout duration forces the 2*layoutDur > remaining
// guard to trip on the first re-layout.
e.settle(ctx, doc, 400, 300, fonts, rp, time.Hour, nil)
e.settle(ctx, doc, 400, 300, fonts, rp, time.Hour, nil).Close()

if !strings.Contains(strings.Join(logs, "\n"), "re-layout skipped") {
t.Fatalf("expected the deadline guard to skip re-layout; logs:\n%s", strings.Join(logs, "\n"))
Expand Down
35 changes: 29 additions & 6 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,12 @@ func viewportSize(viewport image.Rectangle) (vpW, vpH int) {
// anchor hit-map always describe the exact same JS-settled DOM and geometry. On
// return doc.Title reflects any document.title a script set.
func (e *Engine) renderCore(ctx context.Context, doc *Document, vpW, vpH int, fonts *paint.Fonts) *renderPass {
return e.renderCoreStaged(ctx, doc, vpW, vpH, fonts, nil)
rp, sess, stopHeap := e.renderCoreStaged(ctx, doc, vpW, vpH, fonts, nil)
stopHeap()
if sess != nil {
sess.Close()
}
return rp
}

// renderCoreStaged is renderCore with an optional per-stage hook that drives
Expand All @@ -326,7 +331,16 @@ func (e *Engine) renderCore(ctx context.Context, doc *Document, vpW, vpH int, fo
// returned pass. onStage==nil reproduces the original batch pipeline exactly (a
// single images-then-layout pass, no wasted pre-image layout), so RenderDocument
// / RenderDocumentWithLinks are byte-identical to before.
func (e *Engine) renderCoreStaged(ctx context.Context, doc *Document, vpW, vpH int, fonts *paint.Fonts, onStage func(stage string, rp *renderPass)) *renderPass {
//
// The returned Session is nil when DisableJS is set (settle never ran) and
// otherwise LIVE — the caller decides whether to close it immediately (every
// existing caller does, preserving today's one-shot-render behavior) or keep
// it open across later interactions (Engine.Open, interactive.go). The
// returned func releases the heap-watchdog goroutine that guarded the settle
// stage (a no-op when DisableJS); call it at the same point the Session is
// closed — see the doc comment on settle for why it must not be called
// early on a Session kept alive past this function's return.
func (e *Engine) renderCoreStaged(ctx context.Context, doc *Document, vpW, vpH int, fonts *paint.Fonts, onStage func(stage string, rp *renderPass)) (*renderPass, *js.Session, func()) {
// A page's images are loaded once here and again after every settle pass
// that mutates the DOM (a script may add/swap images), each time re-fetching
// unconditionally — e.ImageCache is opt-in and nil by default, so without
Expand Down Expand Up @@ -403,10 +417,19 @@ func (e *Engine) renderCoreStaged(ctx context.Context, doc *Document, vpW, vpH i
// script) once memory balloons past MaxJSHeapBytes, so a page like GitHub's
// huge module graph falls back to its already-good pre-script layout instead
// of driving the process toward OOM.
// stopHeap releases the heap watchdog goroutine (and cancels sctx, which the
// live Session's execute() loop watches to interrupt a runaway script). It is
// returned rather than called here: a one-shot caller (renderCore,
// buildProgressive) calls it right away, same as before; Engine.Open keeps
// it uncalled until the caller is done interacting with the page (calling it
// early would cancel sctx and permanently poison every later script run on
// this same live Session — see js ctx-cancel handling in js/js.go).
var sess *js.Session
stopHeap := func() {}
if !e.DisableJS {
sctx, stop := e.heapGuardedContext(ctx)
e.settle(sctx, doc, vpW, vpH, fonts, rp, initialLayout, onStage)
stop()
var sctx context.Context
sctx, stopHeap = e.heapGuardedContext(ctx)
sess = e.settle(sctx, doc, vpW, vpH, fonts, rp, initialLayout, onStage)
}

// A script may have set document.title; re-derive it so RenderInfo reports the
Expand All @@ -425,7 +448,7 @@ func (e *Engine) renderCoreStaged(ctx context.Context, doc *Document, vpW, vpH i
rp = fb
}
}
return rp
return rp, sess, stopHeap
}

// heapGuardedContext derives a context from parent that is cancelled once the
Expand Down
189 changes: 189 additions & 0 deletions interactive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright (c) the go-webengine/engine authors.
// SPDX-License-Identifier: BSD-3-Clause

// This file adds a persistent counterpart to the one-shot Render family: a
// LiveDocument keeps a page's JS runtime alive across separate, later calls
// instead of tearing it down at the end of one render. It exists for a host
// that synthesizes real user interaction (focus a field, type a character,
// click a button) spread out over time — a plain Render per interaction
// would discard the JS runtime's own state (anything a script holds that
// isn't reflected back into the DOM/attributes, e.g. a controlled input's
// component state) between each one, silently diverging from what a real
// browser would do. See dynamic.go's settle, whose Session this reuses
// without re-running the page's initial scripts.
package engine

import (
"context"
"errors"
"image"

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

// ErrClosed is returned by a LiveDocument method called after Close.
var ErrClosed = errors.New("engine: LiveDocument is closed")

// LiveDocument is a page whose JS session stays open across separate calls.
// Not safe for concurrent use — like js.Session, one goroutine drives one
// LiveDocument at a time. Callers interact through Interact (this file) and
// higher-level methods added by later phases (hit-testing, synthetic
// focus/type/click); Close releases the session and heap watchdog and must
// be called exactly once, whether by an explicit navigation-away or by the
// host window closing.
type LiveDocument struct {
e *Engine
doc *Document
rp *renderPass
sess *js.Session
fonts *paint.Fonts
viewport image.Rectangle
vpW, vpH int
stopHeap func()
closed bool
// prevLinks is the stylesheet <link> set digest AS OF THE LAST SETTLED
// FRAME — carried across Interact calls (unlike settle's own prevLinks,
// which is scoped to one render). resettle compares against this to
// decide whether an interaction's own mutation touched the link set,
// so it must be read BEFORE fn() runs, never recomputed from the
// already-mutated DOM inside the same call — see
// TestLiveDocumentResettleRefetchesChangedStylesheets.
prevLinks uint64
}

// Open fetches url and renders it exactly like Render, but keeps the page's
// JS session alive afterward instead of closing it. The caller must call
// Close when done with the page (a real navigation opens a fresh
// LiveDocument via another Open — this type is for staying ON one page
// across interactions, not for the page itself changing).
func (e *Engine) Open(ctx context.Context, rawurl string, viewport image.Rectangle) (*LiveDocument, error) {
doc, err := e.Fetch(ctx, rawurl)
if err != nil {
return nil, err
}
return e.OpenDocument(ctx, doc, viewport)
}

// OpenDocument is Open for an already-fetched Document — the interactive
// counterpart to RenderDocument, and what fixture tests use.
func (e *Engine) OpenDocument(ctx context.Context, doc *Document, viewport image.Rectangle) (*LiveDocument, error) {
fonts := paint.NewFonts()
vpW, vpH := viewportSize(viewport)
rp, sess, stopHeap := e.renderCoreStaged(ctx, doc, vpW, vpH, fonts, nil)
return &LiveDocument{
e: e, doc: doc, rp: rp, sess: sess, fonts: fonts,
viewport: viewport, vpW: vpW, vpH: vpH, stopHeap: stopHeap,
prevLinks: linkKey(doc.Root),
}, nil
}

// Document returns the live DOM's root document, for a caller that needs to
// walk or hit-test the current tree (e.g. a later phase's element index).
// The returned *Document is the SAME one Open/OpenDocument was given — its
// Root mutates in place as the page's own scripts and any synthetic
// interaction change it.
func (d *LiveDocument) Document() *Document { return d.doc }

// Frame renders the document's CURRENT state to an image, exactly like
// Render's return value — call it after Open, and again after each
// Interact, to get what the user should now see.
func (d *LiveDocument) Frame() (*image.RGBA, *RenderInfo, error) {
if d.closed {
return nil, nil, ErrClosed
}
img := d.e.newCanvas(d.doc, d.rp, d.viewport, d.vpW)
paint.PaintFull(img, d.rp.box, d.fonts, d.rp.imgs, d.rp.bgImgs)
return img, renderInfo(d.doc, d.rp), nil
}

// Interact runs fn — which mutates the live DOM directly, or (in a later
// phase) dispatches a synthetic event through the live Session — and then
// resettles: re-cascades and re-lays-out from whatever fn changed, WITHOUT
// re-running the page's initial scripts. That last part is the entire point
// of LiveDocument: the JS runtime's own state (closures, timers, anything a
// script holds that the DOM alone doesn't capture) survives across
// repeated calls, exactly as it would across two real keystrokes in a
// browser. It returns the resulting frame.
func (d *LiveDocument) Interact(ctx context.Context, fn func()) (*image.RGBA, *RenderInfo, error) {
if d.closed {
return nil, nil, ErrClosed
}
fn()
d.resettle(ctx)
return d.Frame()
}

// resettle is settle's per-pass mechanic (re-cascade, re-layout, re-run any
// newly-inserted <script>, reload images if the layout actually changed),
// reimplemented here as its own bounded loop rather than shared with
// dynamic.go's settle — settle's loop is driven by comparing DOM signatures
// across a SCRIPT's own passes and is deliberately left untouched; here the
// caller (Interact) already knows something changed, so there is no
// signature gate to evaluate first. Both call the same underlying pure
// functions (css.CascadeVW, layout.LayoutDocument, layoutWithContainers,
// renderedEmpty, reskin, newLayoutMetrics) settle does, so a page's
// rendering behavior is identical either way. Nothing in this pipeline can
// currently fail (no network/IO on the hot path beyond the image reload,
// which — like settle's own — never returns an error), so unlike Open/Frame
// this has no error return; one is added if a future phase (e.g. native
// form submission) introduces a real fallible step here.
func (d *LiveDocument) resettle(ctx context.Context) {
relaidOut := false

for pass := 0; pass < maxSettlePasses; pass++ {
if k := linkKey(d.doc.Root); k != d.prevLinks {
d.rp.sheets = d.e.fetchExternalSheets(ctx, d.doc, float64(d.vpW))
d.prevLinks = k
}
newSm := css.CascadeVW(d.doc.Root, float64(d.vpW), d.rp.sheets)
newBox, newHeight := layout.LayoutDocument(d.doc.Root, newSm, float64(d.vpW), d.fonts, d.rp.imgSize)
newSm, newBox, newHeight = layoutWithContainers(d.doc.Root, d.rp.sheets, float64(d.vpW), d.fonts, d.rp.imgSize, newSm, newBox, newHeight)

// Same wipe-guard settle applies: never let a pass erase an
// already-good render (see dynamic.go's settle for the full
// rationale — a broken script re-render observed live on react.dev).
if !renderedEmpty(d.rp.box, d.rp.height) && renderedEmpty(newBox, newHeight) {
reskin(d.rp.box, newSm)
d.rp.sm = newSm
break
}
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))
// 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.
if !d.sess.RunPending() {
break
}
}

d.doc.Title = dom.Title(d.doc.Root)

if relaidOut {
d.rp.imgSize, d.rp.imgs = d.e.loadImages(ctx, d.doc, d.rp.sm, d.vpW)
d.rp.bgImgs = d.e.loadBackgroundImages(ctx, d.doc, d.rp.sm)
d.rp.box, d.rp.height = layout.LayoutDocument(d.doc.Root, d.rp.sm, float64(d.vpW), d.fonts, d.rp.imgSize)
d.rp.sm, d.rp.box, d.rp.height = layoutWithContainers(d.doc.Root, d.rp.sheets, float64(d.vpW), d.fonts, d.rp.imgSize, d.rp.sm, d.rp.box, d.rp.height)
}
}

// Close releases the JS session's watchdog goroutine and the heap-guard
// goroutine. Idempotent.
func (d *LiveDocument) Close() {
if d.closed {
return
}
d.closed = true
if d.sess != nil {
d.sess.Close()
}
if d.stopHeap != nil {
d.stopHeap()
}
}
Loading