We've auto-injected `*Frame` into WebApi since forever (used to be called *Page,
but then we split *Page / *Frame, but same same). And it worked wonderfully:
there was always a single *Frame, so the Frame/Context that the JS was being
executed in HAD to be the *Frame that a node belonged to.
But with the addition of iframe and popups, that truth no longer holds. The
*Frame executing the JS (which is the frame that we auto-inject) isn't
necessarily the *Frame that owns a Node.
This is particularly problematic because the *Frame holds a bunch of node/
element data, e.g. `_element_datasets`. So now the DataSet that you get back
depends on the context in which its called..they don't have identity and can
fall out of sync. Some code calls node.ownerFrame() / node.ownerDocument(), but
not all and the hope is to address this throughout the codebase once and for
all.
This is the first in a series of commits meant to fix this long-standing
issue. All it does is change the node.ownerFrame() return value from *Frame to
?*Frame. It's up to each caller to decide how to handle a frameless node, e.g.
clicking a frameless link should not navigate.
Visibility probes no longer decide whether an element's style attribute
gets parsed into a CSSStyleProperties object. The three layout readers
(getElementAxis, positionStyle, horizontalPosition) create it on demand
through Element.inlineStyle, and StyleManager only ever folds the
attribute text. That removes the scan/materialize mode threaded through
every probe, and a JS layout read now only materializes the elements
whose inline style it actually reads.
With the StyleManager memo, a VisibilityCache/PointerEventsCache hit
costs the same hash probe as a memo hit, so the caches only added a
second probe per ancestor, a call_arena allocation per element, and
plumbing through SemanticTree, AXNode, CDP, links, ResizeObserver and
elementFromPoint. checkVisibilityCached becomes isVisible.
In main, there's a `getId`, and `getClassName` (etc...) getter on Element. But
these all `orelse ""`, because that's what the WebAPI wants. Internally though,
most code want the optional. The result is that _many_ places do:
```zig
el.getAttributeSafe(comptime .wrap("id"))
```
instead of:
```zig
el.getId()
```
This is a bit tedious AND, it means that when we improve `Element.getId` (1) no
internal caller benefits from it. This commit makes the element getters return
the optional (`?[]const u8`) and updates every callsite to use the new getter.
The `orelse ""` needed by the WebAPI is moved to the JsApi bridge.
(1) https://github.com/lightpanda-io/browser/pull/3457
1. Abstract "Driver". Non-CDP things that referenced *CDP now reference a Driver
2. Move the NodeRegistry out of CDP. This created an artificial link between
agent / mcp and CDP
3. Add BiDi driver with enough to navigate to a page
findElement and interactiveElements had their own role table and name
algorithm, which knew nothing of <label for>, wrapping labels or
aria-labelledby, so a control the tree showed as "Email address" was
unfindable by that name. Both now come from AXNode, with the element's
text as the name of last resort for elements that have no role (a div
interactive only by its listener or tabindex). getName takes an
optional label index so a walk resolves <label for> with one document
scan; the semantic tree walk uses it too.
StyleManager ultimately ends up calling el.getOrCreateStyle() which either
returns the element's CSSStyleProperties OR (creates it AND stores it in the
Frame._element_styles for future lookups).
The goal behind this caching is twofold:
1 - Performance of not having to reparse the "style" attribute
2 - Identity: two calls from JS to get the properties should return the same
value
(2) is non-negotiable, so the 'getOrCreate' _has_ to exist for JS-facing APIs.
But (1) is CPU vs memory optimization that we've decided should always favor the
CPU. But, in any case where we dump an entire tree, that memory cost can be
significant (# of elements with a style attribute) and the CPU gains are
questionable (it isn't like a JS loop re-checking an element's properties, it's
a one-time dump). So, the StyleManager now takes a comptime `InlineAccess` which
is either `.scan` or `.materialize`. When it's `.materialize` it behaves as
before. When it's `.scan` is will use an existing `_element_styles` if available
else it will re-parse but not store the value.
1 - TEST_VERBOSE is now off by default
2 - There's a afterEach callback that is automatically run after each tests, it:
a - clears the log filters
b - resets the test arena
3 - LogFilter replace with
a - testing.silenceLog(&.{...scopes...}); to silence all logs for the given
scopes.
b - testing.expectLog(&.{...scopes}); to set log expectations, 1 per log.
The goal here isn't so much to expect logs (though, you can do that),
but rather to silence an expected # of logs, without silencing more.
Built against https://github.com/lightpanda-io/zig-v8-fork/tree/zig-0.16 but
it doesn't require a new v8 build.
Built against https://github.com/lightpanda-io/boringssl-zig/tree/zig-0.16
since the current fork we point to isn't updated.
A global std.Io instance, lp.io. Way easier this way and requires 0 changes to
our libcurl integration / event loop.
Network code uses a new layer that does what Zig 0.15's posix package used to
do. Again, quicker migration that way. But, as long as we have the global IO,
and given the half-baked nature of networking in std.Io 0.16, this just makes
sense. Things can be migrated as needed.
The std.time.* -> std.Io.Timestamp/Clock/Duration resulted in _a lot_ of
changes. ArrayList = .{} -> ArrayList -> .empty also resulted in a lot of
changes, but that's obviously superficial. As is the trimLeft/trimRight ->
trimStart/trimEnd rename.
Locking adopt the `Uncancelable` variants, e.g. mutex.lockUncancelable() to
preserve the error-free signature (and, because cancellation would be something
we'd have to put more thought into).
std.json.ObjectMap is now unmanaged, so the allocator had to be passed along.
However, there's still a deprecated managed variant of MemoryPool, so I switched
to it (we can do a small follow up PR to move to the unmanaged after).
I tried use_llvm = false, but it locks my computer, consuming RAM until MacOS
gives me a popup I've never seen before, begging me to start killing processes.
Agent and the networking stuff saw the most significant changes.
Fixes WPT /dom/nodes/Text-wholeText.html: wholeText must return the
concatenated data of the contiguous exclusive Text nodes (adjacent Text
siblings on both sides of the node) in tree order; we returned only the
node's own data.
The old own-data behavior is what every internal caller (semantic tree,
markdown dump, AX names, textarea default value, textContent) actually
wants, since they iterate the text nodes themselves; those now use the
new Text.ownData helper, and getWholeText implements the spec walk
(single-node case stays allocation-free).
Coverage: /dom/nodes/Text-wholeText.html 0/1 -> 1/1 (fully green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds deduping to attribute names. This has two benefits. First, it results in
fewer dupes/allocations. Second, it allows finding an attribute name by a single
pointer comparison.
Say we need to create an attribute attr1=val1 (from the parser or JS, doesn't
matter). We:
1 - Lookup "attr1" in String.intern, it doesn't exist
2 - We lookup or create "attr1" in Frame._attribute_names
Whether the name was found in String.intern, found in Frame._attribute_names or
created in Frame._attribute_names, any attribute name "attr1" always points to
the same value.
Now say we want to lookup the value "attr1". We _could_ iterate the attribute
list and do a string comparison on each attribute. OR, we could apply the same
canonicalization to that input as we did when building the list. We can do this
via a read-don't-create API. If it doesn't find it, than that attribute was
never canonicalized and thus, cannot exist (early exit!). If it IS found, then
we now have the pointer for "attr1" that all attributes with "attr1" use, so
we can just do a pointer comparison.
We also replace the name: []const u8, value: []const u8 with a packed
representation (where len: u32, instead of u64) meaning every attribute entry
saves an extra 8 bytes (on top of the 16 bytes saved by the previous commit)
An element's attributes are currently stored as a ?*Attribute.List which is an
intrusive doubly linkedlist of Attribute.Entry. This has two small benefits:
1 - Elements with no attributes only grow by 8 bytes
2 - Attribute list mutation (additions/deletion) are linkedlist cheap
This commit embeds an ArrayList-like structure directly in Element. The impact
being:
1 - Elements with no attributes now grow by 16 bytes (+8)
2 - Elements with 1+ attribute shrink from 32 -> 16 bytes (-16)
3 - Mutations are more expensive
4 - Fewer indirections (and better cache locality)
5 - Much fewer allocations (4 attributes go from 5 allocations to 1)
6 - Every Attribute shrinks by 16 bytes (no need for next/prev link)
While looking at a few popular sites (amazon product, redit post, ...), the
majority of elements have 1+ element and most attributes are never accessed in
JS and, when they are, reads are more frequent than writes (in fact, even
internally to support other WebAPIs, reads far outweigh writes).
There's virtually no real world site where this shouldn't reduce memory usage
by hundreds of KB and also improve performance (in a way that isn't significant
to the overall page loading though).
Replaces the 1 (+1 inflight) page design of session for an unlimited number of
pages. The main goal is to support a more efficient async goto (1). Without this
commit, async goto has two implementation: multiple browser, which is resource
intensive (thread + isolate per page), bolting it onto sub-frames (like iframe
or popups). The issue with the 2nd version (which I originally suggested) is
that most resources are tied to the Page. So even if an async goto "page" (which
would map to a Frame) is released, most things stay in memory, including the
DOM (page.factory) and the V8::Context.
This new approach adds multiple page support to sessions. The advantage is
pretty clear: the existing memory model (page-tied resources) becomes a strength
of the design, rather than a weakness.
This change is not tirivla, but the diff is inflated by 2 large mechanial
changes, so it isn't _that_ big either. That said, I'd divide this into four
parts.
1 - The old concept of _active/_pending is now baked into the Page itself. A
Page has a `replaces: ?*Page = null` and `replacement: ?*Page = null` field.
2 - Because of #1 above, the Session now just has an `pages: ArrayList(*Page)`.
As much as possible, single-page APIs, like `removePage` no longer exists. We're
trying to present a consistent multi-page API. See #3.
3 - References to *Page and *Frame have always been dangerous. For example,
`lightpanda.fetch` has a block to scope `frame`:
```zig
{
const frame = try session.createPage();
// frame isn't safe to use after navigate, it can be swapped out
_ = try frame.navigate(...)
}
```
While we still hand out *Page and *Frame (user-beware), some of the more
important APIs now take a frame_id which Session can resolve. Furthermore,
createPage now returns a PageHandle which is a safe wrapper around a page/frame.
4 - Two large mechanical changes were made:
a - Many tests were superficially changed to account for new naming or use
a new test-helper to preserve the single-page illusion (because 99% of
tests _are_ single-page)
b - Many CDP changes where `bc.session.currentFrame()` -> `bc.mainFrame()`).
This isn't to say CDP changes are meaningless, but it's mostly 1 change
about how the "main" target/frame_id is tracked (by CDP itself, rather
than the Session) that required a number of superficial changes to
accomodate
`Runner` remains largely single-page focused. Runner and some MCP/Agent tools
continue to be tied to the "currentFrame" or "primaryPage". The Runner is
something I want to address in a follow-up PR, but we need to figure out what
it means to "wait" for multiple pages.
As much as possible, Session becomes multi-page native and has no concept of
a special/first/primary/main page. Users of session become responsible for
tracking pages of interest.
This change is not trivial, but the diff is inflated by 2 large mechanical
changes:
1 - Many tests
(1) https://github.com/lightpanda-io/browser/pull/2759
Introduces `SelectorPath` to generate a unique, minimal CSS selector
for a given element. This selector is now returned in `nodeDetails`
to simplify element targeting for drivers.
Introduces `Input.getRedactedValue` to mask password values in
LLM-facing dumps (semantic tree, forms, AXNode) instead of
exposing raw values or using ad-hoc checks.
Follow up to https://github.com/lightpanda-io/browser/pull/2200
This change is actually pretty mundane, but a bunch of files that used to
take a *Session (e.g. every WebAPI releaseRef and deinit) now take a *Page.
This aims to separate the 2 lifetimes currently managed by Session by moving
the "Page" lifetime to a dedicated container: Page. Ultimately, the goal is to
remove the 1-page-per-session limit of the current design. Not to explicitly
support multiple pages per session (though, that's more possible now), but
in order to better emulate Chrome where, during a navigation event, the old and
new page both exist.
This is to pave the way for introducing a new "Page" container, which will take
over the page lifecycle currently burdening Session. The ultimate goal of that
is to allow the Session to have multiple pages (mostly for better transitions
between pages), which is hard to do now since the Session has so much state.
This rename was aggressive, e.g. currentPage() -> currentFrame() so that, when
the new Page container is added, you won't see "currentPage()" and wonder:
"Does 'currentPage' mean the new Page container, or the Frame (which
used to be called Page)".
@import("lightpanda") where needed.
Would also like to do this for String, Page, Session and js which all stand out
as types that are use across the codebase.
I know that a few devs are doing this in new work and I haven't heard anyone
voice an objection.
Whenever we resolve a URL, say from `anchor.href`, we should consider the
document's charset when encoding the querystring. This probably isn't the
most important feature, but it makes tens of thousands of WPT cases pass, e.g
/encoding/legacy-mb-tchinese/big5/big5-encode-href-errors-han.html?3001-4000 and
/encoding/legacy-mb-japanese/euc-jp/eucjp-encode-href-errors-han.html?17001-18000
DOM elements previous called `URL.resolveURL(...)`. They now call
`self.asNode().resolveURL(...)`, where `Node#resolveURL` will provide the
document's charset.
These new optional parameter run AFTER --wait-until, allowing the (imo) useful
combination of `--wait-until load --wait-script "report.complete === true"`.
However, if `--wait-until` IS NOT specified but `--wait-selector/script` IS,
then there is no default wait and it'll just check the selector/script. If
neither `--wait-selector` or `--wait-script/--wait-script-file` are specified
then `--wait-until` continues to default to `done`.
These waiters were added to the Runner, and the existing Action.waitForSelector
now uses the runner's version. Selector querying has been split into distinct
parse and query functions, so that we can parse once, and query on every tick.
We could potentially optimize --wait-script to compile the script once and call
it on each tick, but we'd have to detect page navigation to recompile the script
in the new context. Something I'd rather optimize separately.
A Page now has a StyleManager. The StyleManager currently answers two questions:
1 - Is an element hidden
2 - Does an element have pointer-events == none
This is used in calls such as element.checkVisibility which, on some pages, can
be called tens of thousands of times (often through other methods, like
element.getBoundingClientRect). This _can_ be a bottleneck.
The StyleManager keeps a list of rules. The rules include the selector,
specificity, and properties that we care about. Rules in a stylesheet that
contain no properties of interest are ignored. This is the first and likely
most significant optimization. Presumably, most CSS rules don't have a
display/visibility/opacity or pointer-events property.
The list is rules is cached until stylesheets are modified or delete. When this
happens, the StyleManager is flagged as "dirty" and rebuilt on-demand in the
next query. This is our second major optimization.
For now, to check if an element is visible, we still need to scan all rules.
But having a pre-build subset of all the rules is a first step.
The next step might be to optimize the matching, or possibly optimizing common
cases (e.g. id and/or simple class selector)
Adds support for `pointer-events: none` in interactivity classification
and expands `checkVisibility` to include `visibility` and `opacity`.
Refactors CSS property lookup into a shared helper.
This prevents token bloat in JSON/text dumps and ensures that StaticText leaf nodes are not incorrectly pruned when structural containers (like none, table) hoist their text.