Commit Graph
67 Commits
Author SHA1 Message Date
Adrià Arrufat a160b8d552 SemanticTree: resolve the owner frame once in init
Every caller resolved node.ownerFrame(frame) before building a
SemanticTree or calling getNodeDetails, and every one made the same
decision on a frameless node. Move that into SemanticTree.init, which
returns error.FramelessNode, and turn getNodeDetails into a method so
it goes through the same constructor.

With init as the only entry point the per-method assertOwns is
redundant, so drop it along with its copy of StyleManager's helper.
2026-09-17 11:59:01 +02:00
Karl Seguin 13638ff50b internal: Always correct SemanticTree context
Applies the frame-ownership pass to SemanticTree, copying what we did for
StyleManager (1). SemanticTree doesn't visit iframes, so the frame of the root
is the frame/frame._style_manager we need to target for all visited nodes.

Like #3536, it's up to the callers to (a) get the correct frame and (b) decide
what to do on a frameless-node.

(1) https://github.com/lightpanda-io/browser/pull/3536
2026-09-17 12:13:53 +08:00
Karl Seguin d1ea326b44 webapi: better handling of frameless documents
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.
2026-09-13 10:31:30 +08:00
Adrià Arrufat 7b7b7aa9f3 Materialize inline styles where layout reads them
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.
2026-09-11 10:43:31 +08:00
Karl Seguin 2e6999f20b chore: make declarations private if they don't need to be public
This change is 99%  s/pub//   + a handful of dead code removal.
2026-09-10 14:42:09 +08:00
Karl Seguin 3b4088ab62 use node's own frame, not the callers 2026-09-10 11:03:46 +08:00
Adrià Arrufat 1583a4b45f Drop the per-call visibility caches
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.
2026-09-10 10:34:43 +08:00
Karl Seguin a9af75034d perf: improve common element attribute getters
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
2026-09-09 11:22:37 +08:00
Karl Seguin 7c51dfff05 bidi: add skeleton for bidi
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
2026-08-31 22:44:59 +08:00
Adrià Arrufat 9a3585079d cdp: the label index is required, not optional
A fresh index costs the same single walk as the scan it replaced, so
the null path bought nothing and left two lookups to maintain.
2026-08-27 16:10:22 +02:00
Adrià Arrufat 6bff402a24 interactive: take roles and names from AXNode
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.
2026-08-27 16:01:12 +02:00
Karl Seguin e4a36552b8 mem: Optional CSSStyleDeclaration materialization
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.
2026-08-26 13:23:11 +08:00
Karl Seguin c726b022e8 tests: improve test output and test log handling
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.
2026-07-31 07:30:25 +08:00
Karl Seguin 8e42d63c1c zig: Zig 0.16
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.
2026-07-22 13:26:03 +08:00
Francis BouvierandClaude Fable 5 f197fd6388 webapi: Text.wholeText spans contiguous Text siblings
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>
2026-07-15 17:53:10 +08:00
Karl Seguin 4a57e3c402 perf, mem: canonicalize attribute names
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)
2026-07-08 19:14:41 +08:00
Karl Seguin f32606a27b mem: Store Element's attributes as an array, not a linked list
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).
2026-07-08 18:42:55 +08:00
Karl Seguin da6cd66e36 design: multi-page session
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
2026-06-24 07:01:29 +08:00
Adrià Arrufat e3d05aeaca browser: add CSS selector to nodeDetails
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.
2026-06-04 16:34:56 +02:00
Adrià Arrufat 525c3e467c input: centralize password value redaction
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.
2026-05-24 08:36:23 +02:00
Karl Seguin 550fb58f3f Introduce Page (container)
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.
2026-04-23 15:48:13 +08:00
Karl Seguin 2275416505 Page -> Frame
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)".
2026-04-22 08:42:18 +08:00
Karl Seguin 2d20e57f80 Change all @import("...../log.zig") to const log = lp.log;
@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.
2026-04-20 12:40:04 +08:00
Karl Seguin 05229fdc53 Use the document's charset to determine if/how to encode querystring
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.
2026-04-10 16:47:42 +08:00
Karl Seguin 4ad8282e75 Merge pull request #2047 from lightpanda-io/fancy-wait
Add --wait-selector, --wait-script and --wait-script-file options to …
2026-03-31 20:59:48 +08:00
Adrià Arrufat 008235222b SemanticTree: reorder getNodeDetails params 2026-03-31 07:29:33 +02:00
Adrià Arrufat fc057a3bb3 SemanticTree: add checked state to node data and output 2026-03-31 06:43:01 +02:00
Karl Seguin ab6c63b24b Add --wait-selector, --wait-script and --wait-script-file options to fetch
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.
2026-03-31 12:30:46 +08:00
Adrià Arrufat 367d20d39f SemanticTree: simplify lp.String.wrap calls 2026-03-31 05:20:32 +02:00
Adrià Arrufat 9c8fe9b20f SemanticTree: Add nodeDetails tool
Adds a tool to retrieve detailed node metadata and updates the
semantic tree to track and display the disabled state of elements.
2026-03-30 16:38:23 +02:00
Adrià Arrufat 5404ca723c SemanticTree: move NodeData initialization closer to usage 2026-03-20 10:18:16 +09:00
Adrià Arrufat e56ffe4b60 SemanticTree): use WalkContext for walk function 2026-03-20 10:12:57 +09:00
Adrià Arrufat a74e97854d Merge branch 'main' into css-improvements 2026-03-20 09:46:31 +09:00
Adrià Arrufat 5062273b7a SemanticTree: use CDPNode.Id for NodeData id 2026-03-19 20:29:54 +09:00
Adrià Arrufat 9c2393351d SemanticTree: simplify max_depth logic 2026-03-19 20:25:20 +09:00
Adrià Arrufat e997f8317e SemanticTree: add tests for backendDOMNodeId and maxDepth 2026-03-19 12:25:02 +09:00
Adrià Arrufat 7a7c4b9f49 SemanticTree): add backendNodeId and maxDepth support 2026-03-19 10:18:08 +09:00
Adrià Arrufat cbab0b712a SemanticTree: simplify TextVisitor printing logic 2026-03-18 20:07:11 +09:00
Karl Seguin e29778d72b Introduce StyleManager
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)
2026-03-18 17:52:57 +08:00
Adrià Arrufat e1b14a6833 SemanticTree: enable prune by default 2026-03-18 11:25:38 +09:00
Adrià Arrufat 015edc3848 SemanticTree: implement interactiveOnly filter and optimize token usage 2026-03-18 10:56:56 +09:00
Adrià Arrufat f0c9c262ca Merge branch 'main' into css-improvements 2026-03-14 20:36:50 +09:00
Adrià Arrufat 3fde349b9f webapi): reorder css function params and merge pointer events 2026-03-14 20:31:00 +09:00
sjhddh c4176a282f fix: resolve memory leak in Option.getText() by using page arena 2026-03-14 06:50:26 +00:00
Adrià Arrufat f37862a25d perf: cache css properties for visibility and interactivity
Introduces `CssCache` to store computed CSS properties, avoiding
redundant stylesheet lookups during DOM traversals.
2026-03-13 14:00:07 +09:00
Adrià Arrufat 84d76cf90d browser: improve visibility and interactivity CSS checks
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.
2026-03-13 13:33:33 +09:00
Adrià Arrufat e0343a3f6d Replace ArrayListUnmanaged with ArrayList 2026-03-12 22:23:59 +09:00
Adrià Arrufat 65d7a39554 SemanticTree: use payload captures for CData.Text checks
Improves conciseness and idiomatic Zig style by replacing .is(CData.Text) != null and .as() with direct payload captures in if statements.
2026-03-11 16:39:59 +09:00
Adrià Arrufat 37735b1caa SemanticTree: use StaticStringMap for structural role check
Improves performance and readability of isStructuralRole. Also includes minor syntax cleanup in AXNode.
2026-03-11 16:37:24 +09:00
Adrià ArrufatandKarl Seguin 1866e7141e SemanticTree: cast with as
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-11 16:33:39 +09:00