Report a viewport, a location origin, and real media queries - #34
Report a viewport, a location origin, and real media queries#34pathscale wants to merge 12 commits into
Conversation
…0-rc.4 local-ui/package.json still asked for @pathscale/ui ^2.5.0, solid-js ^1.9.5 and solid-layouts ^0.1.3, from before the chrome was ported. Those are the peer ranges of a workspace member, so bun resolved them for the whole workspace and installed solid-js 1.9.14 and @pathscale/ui 2.5.0 -- the app has been building against Solid 1 while its own package.json asked for 2.0.0-rc.0. Widening the member's ranges is what makes the app's pins take effect. Also adds the @iconify-json sets. index.css @sources @pathscale/ui, which uses mdi-- tokens internally, and nothing installed them, so those icons resolved to nothing. babel-preset-solid moves to rc.2 behind a caret; the exact rc.0 pin could not take a fix. Verified: typecheck, biome, the 15 frontend tests, a production build, and the built chrome rendering its address bar, title bar and tabs. The scripts/solid-2-boundary.ts workaround stays. Its exit condition is solid-layouts-oxc consulting boundaryFor, which 0.2.2 now does -- but rsbuild-plugin-solid-layouts 0.2.1 carries its own 0.2.1 copy of the validator, and a bun override does not dedupe it. The pair goes when that plugin publishes against 0.2.2.
Nothing here uses daisyUI; the comment described the cascade problem in terms of a package this repository does not install.
A patch release: no exports added or removed against 2.11.9, and the same peer range. The caret already admitted it; this pins the lockfile to it.
`scripts/render-check.sh` has documented and exported CHUZZ_CAPTURE_WIDTH and CHUZZ_CAPTURE_HEIGHT since it was written, and the capture entry point read neither: it passed a literal 1440 by 960. Every capture was that size whatever the caller asked for, and a run at another width produced a byte-identical tree, which is how this surfaced. That matters now because captures are about to be compared against a reference browser. Two engines laying the same page out at different widths disagree on every percentage width, every centred box and every responsive breakpoint, so an unhonoured viewport turns a diff into noise that reads exactly like a rendering fault. Only CHUZZ_CAPTURE_SCALE was wired up, so the fix follows its shape.
The prefetch walks the parsed HTML and loads every script it names. A page that builds a `<script>` from JavaScript asks for a URL nobody knew about until the page was already running, and that request reached `DefaultScriptFetcher`, which serves `file:` and `data:` only. The script was dropped with `unsupported URL scheme for script: https`, which reads like a policy decision rather than the missing capability it is. Measured over a hundred-site corpus, this is the single most common engine defect: **26 sites**, a quarter of the corpus. It hides well, because scripts the parser found load perfectly, so a page fails only in the parts it assembles itself, and jQuery going undefined on seven sites looks like its own bug rather than a consequence of never having been fetched. `ScriptFetcher::fetch` is synchronous, because classic scripts execute in document order, so the network call blocks. It runs on the page's own provider and so keeps the per-origin connection cap the rest of the loads obey, and it is bounded by a timeout: a server that accepts and never answers would otherwise hang the capture, and a missing script is better than a run that never ends. Verified by re-capturing the six worst-affected sites: dropped scripts went 12, 6, 4, 2, 2, 2 to zero everywhere. Rendering barely moved, which is the honest result rather than a disappointing one. On one site the error count rose from 9 to 14 with a new kind, `unescape is not defined`: the scripts now run and reach the *next* missing global. This unblocks a layer; the missing web APIs behind it are what turn that into pixels. The test serves a script from a real socket and asserts a synchronous fetch completes a round trip from inside the runtime driving the page. Its accept loop has a deadline rather than blocking: restoring the defect makes no connection at all, and a blocking accept hangs the run instead of failing it. Confirmed by restoring the defect, which fails the test in ten seconds. Only the capture path. `browser.rs` has the same fetcher and is reached from a synchronous poll with no runtime handle to hand, so it needs a stored handle and is left for its own change.
The previous change fixed the capture path and left the window with the same defect, so the browser people actually use still dropped a quarter of the corpus's scripts while the tool measuring it did not. Measuring correctly is not the point of the exercise. The fetcher moves into `script_fetch.rs` and both paths share it, rather than the window growing a second copy of logic that must then be kept in step. The prefetch-then-fall-back shape was already duplicated between the two; it is now in one place. The deadline is per caller, and the two differ for a reason worth stating. `ScriptFetcher::fetch` is synchronous and page scripts run on the UI thread, so in the window this blocks everything, other tabs included, for as long as it waits. Five seconds there against the capture's ten: a capture is unattended and a dropped script costs it the fidelity it exists to provide, while a window has someone watching the frame. Neither number is the real answer. The real answer is an asynchronous script-loading path in the engine, which would not have to choose, and that is a change to blitz-script rather than to this. The window path is the same code the tests cover and the capture exercises, but it is not itself verified in a window: this machine has no screen access, and the control socket sees the chrome document rather than the page's sub-document. Re-captured after the refactor to confirm the tested path did not move: dropped scripts still zero on the worst-affected site.
Most of the web fetches its own content, so an engine without these does not render a slightly incomplete page: it renders the shell and stops. In a hundred-site corpus `XMLHttpRequest` was missing on 6 sites and `fetch` on 4 by name, and the pages laying out at 37% and 44% of a reference browser's height are the same fact counted another way. `blitz-script` has no way to register a host function, which is why this looked like an engine change. It is not. Three things it already exposes are enough: `window.ipc.postMessage` carries a string from JavaScript to the host, `eval` carries one back, and `add_poll_hook` runs work on the document thread. The shim parks a promise and posts the request; the handler spawns the real fetch on the page's own provider, so it obeys the same per-origin connection cap as every other load; the poll hook hands the answer back by evaluating a call to the resolver. Nothing blocks, and that is the difference from the script fetcher landed earlier. A `<script src>` is synchronous because the HTML spec says scripts execute in document order, so that one has to block and its deadline is a compromise. `fetch` is asynchronous by definition, so this can honour it: the window keeps painting and the answer arrives on a later poll. The deadline exists only so a server that never answers cannot hold a connection forever. Both `set_ipc_handler` calls already in the tree are no-ops on the *chrome* document, so nothing was competing for the channel. The tests are end to end rather than unit: a real page, a real socket, a promise resolved through the whole bridge, asserting the page read `42` out of the JSON and the body out of the XHR. Their accept loops carry deadlines, so a regression that never issues the request fails rather than hanging. Response headers are not carried yet, and `setRequestHeader` is accepted and ignored: a page that only sets an Accept should not throw, and a page that depends on reading headers back is not yet served. Sync XHR is not supported.
Ranked by sites affected over a 104-site corpus, after the runtime-script-fetch fix stopped scripts being dropped and let more of them run far enough to reach these: `Image` (4 sites), `TextEncoder` (2), `AbortController` (2), and `ResizeObserver`, `Path2D`, `ShadowRoot` and `unescape` (1 each). Real implementations, with nothing invented: - `escape` / `unescape`, the Annex B pair. Pure string transforms with a specification, so there is nothing to fake. - `TextEncoder` / `TextDecoder`, real UTF-8 both ways, including surrogate pairs, unpaired surrogates as U+FFFD, and overlong sequences rejected. The callers that reach for these are hashing or framing bytes, where an encoder that got the multi-byte cases wrong would hand back a plausible array of the wrong length and fail somewhere else entirely, as a bad digest. - `AbortController` / `AbortSignal`, including `abort`, `timeout` and `any`. The whole of it is bookkeeping over a flag and a listener list, with no engine support to wait for. - `String.prototype.substr`. Also Annex B, also absent, and this one is not on the corpus list and cannot be: the report counts names a page looked up and did not find, and a missing method on an existing prototype raises `TypeError: not a callable function` instead, an error class counted nowhere. It surfaced from writing `unescape` in terms of it and watching that throw. Stubs, each labelled as one in the file: - `Image` reports every image as loaded, asynchronously, without fetching. Most constructed `Image`s are preloaders that only need the callback. Code that waits for the load and then reads pixels or natural dimensions gets nothing, and the zero dimensions are left honest rather than invented for that reason. Images the document references are still fetched and painted by the engine. - `ResizeObserver` never fires, unlike the `IntersectionObserver` above it. The difference is what an invented entry would have to say: visibility has an answer that is right for most of a page, and a size does not. The only entry this could deliver carries a zero `contentRect`, and a grid that divides by that width computes zero columns and renders nothing. - `Path2D` really accumulates its path; what is missing is a canvas context to read it. - `ShadowRoot` is declared so `instanceof` is answerable and nothing is an instance of it, which is the truthful answer for an engine with no shadow trees. Deliberately still absent, with the reasoning in the file and a test that fails if either appears without real data behind it: - `getComputedStyle` (3 sites). A stub answering '' for every property is worse than the ReferenceError it replaces: today the script throws and stops, which is visible, and with a lying stub it continues, measures nothing and lays the page out wrongly, which reads as an engine bug. - `ReadableStream` (2 sites). A page reaching for it wants incremental delivery, and a stub can only hand over everything at once or nothing. The shim is a JavaScript string in a Rust file that nothing else in the build parses, so a syntax error in it is not a compile error: it is a page that renders as if the shim were absent, on every site. The tests evaluate it the way a page does and read the answers back.
`AbortController` is real now, so the consumer side can use it. `fetch` reads `init.signal`: an already-aborted one rejects without touching the network, and one that aborts later settles the promise with the signal's reason. `XMLHttpRequest.abort` was an empty function and now does the same, firing `onabort` and returning `readyState` to 0. Half of this is honest and the other half is named as what it is not. The request itself keeps running: the host has already spawned it and there is no cancellation channel back, so nothing here closes a socket. What it buys is the observable half, which is the half pages depend on — the promise settles now, and the handler does not run later against a component that has been torn down. `aborting_in_flight_drops_the_answer` asserts exactly that boundary: the server is contacted and does reply, and the page must not see the reply. The tests now install the web-API shim before this one, in the order `browser.rs` and `load_for_capture` both use, because `AbortController` comes from there and this only honours a signal because it does.
…osition The tail of the corpus's missing-globals list, past the table the handover ranked. Three more are honest in JavaScript alone, and the rest are recorded in the file as omissions with the reason, so the next reader does not add them from the report. Real: - `DOMException`. A name, a message and a legacy code, and what pages actually do with one is read `error.name === 'AbortError'`. Adding it also gives the abort machinery the type a browser really throws, so `AbortController`'s default reason is no longer an `Error` wearing the right name. - `top`, `parent`, `self`, `frames`, `frameElement`. There are no frames here, so a document is its own top. Frame-busting code compares `top !== self` and gets `false`, which is correct rather than convenient. - `scrollX` / `scrollY` and their `pageXOffset` aliases, at 0. Honest at load, which is when the scripts that read them run, and the same choice `IntersectionObserver` above already makes: a lazy loader concludes it is at the top of the page and shows what is above the fold. A page that binds a scroll handler and recomputes from these will not see the view move; making them true is engine work. Left out, with the reasoning in the file and a test that fails if any appears: - `NodeList`, `DocumentFragment`, `CharacterData`, `KeyboardEvent`, `HTMLVideoElement`. `ShadowRoot` is declared precisely because nothing in this engine is one, so `instanceof` answering `false` is true. These are the opposite case: the document really does contain node lists and fragments, so an empty constructor would answer `false` about objects that genuinely are instances, and a branch meaning to take the DOM path would silently take the other one. They belong with the engine's DOM bindings, next to the prototypes they have to be related to. - `Intl`. `String(value)` for `NumberFormat` and `DateTimeFormat` keeps a script alive at the cost of rendering unformatted numbers and raw date strings as though they were the page's own output, and the locale data behind a real one is not a shim. - `ActiveXObject`, reported by one site. No browser has it, and a page reaching for it without a `typeof` guard throws in Chrome too. The report is not a defect of ours. - `WebAssembly`, `define` and `require`, which are engine and module support.
… not Real base64, both ways, and the one addition here nothing asked for in advance. Re-capturing the twelve affected sites showed a page fall from 215 nodes to 28, which reads as a regression and is not one: `String.prototype.substr` let its bundle run past the first `TypeError: not a callable function`, far enough to clear the server-rendered markup and rebuild it, and then it hit `atob`. Four of the twelve did the same. A missing global is only counted once something reaches it, so fixing one defect is what surfaces the next, and the low node count was the measurement working rather than failing. `atob` accepts whitespace anywhere and optional padding, which is what a page decoding a header or a data URL relies on, and both throw an `InvalidCharacterError` DOMException on input that is not theirs to decode.
Five gaps in the web API shim, each of which made a working site look broken in a way the console did not explain. - `performance` was absent entirely. `@solidjs/router` reads `performance.getEntriesByType && performance.getEntriesByType(...)`, where the guard covers the call but not the destructure that follows, so every routed page died before its first render. - `matchMedia` answered every query false, including `prefers-color-scheme`, and never parsed a dimension. A responsive layout asking whether it had room for the desktop design was told no. - Nothing reported a viewport. `screen.width` returned `innerWidth || 1440` while `innerWidth` was 0, so a page that asked twice got two different answers and laid out for a phone. - `location.origin` was undefined, though href, protocol, hostname and port were all present. `new URL(path, location.origin)` then returns the path unresolved and fetch rejects it as invalid, which kills a bootstrap inside an async handler with nothing logged. A page that hides itself until that bootstrap finishes stays hidden: a blank white document and an empty console. - `location.host` omitted the port. The sizes are a stated default rather than a measurement: the engine does not expose its own to script, and `innerWidth`, `outerWidth`, the client dimensions and the layout rect all read 0. One constant now feeds `screen`, the `inner`/`outer` pair and the dimension branch of `matchMedia`, because the earlier arrangement had two shims reading each other and recursed without bound the moment both existed.
|
Relationship to #33, since both touch
That last one is the load-bearing part. With So merging #33 alone leaves that fixed-in-name-only. Either land this too, or cherry-pick |
|
Folded into #33 as b23227e, cherry-picked unchanged — one repo, one PR for the release. Nothing is lost: all five shim gaps ( One follow-up the commit message anticipated: it notes the sizes are "a stated default rather than a measurement" because |
Five gaps in the web API shim, each of which made a working site look broken
in a way the console did not explain. Found while validating twelve ported
sites: all twelve rendered blank, and none of them was actually broken.
What was missing
performancewas absent entirely.@solidjs/routerreadsperformance.getEntriesByType && performance.getEntriesByType(...), wherethe guard covers the call but not the destructure that follows, so every
routed page died before its first render.
location.originwas undefined, thoughhref,protocol,hostnameand
portwere all present.new URL(path, location.origin)then returnsthe path unresolved and
fetchrejects it as invalid. That kills abootstrap inside an async handler with nothing logged; a page that hides
itself until that bootstrap finishes stays hidden, so the result is a blank
white document and an empty console.
location.hostomitted the port.matchMediaanswered every query false, includingprefers-color-scheme, and never parsed a dimension. A responsive layoutasking whether it had room for the desktop design was told no, so a
full-width capture rendered the phone design.
Nothing reported a viewport.
screen.widthreturnedinnerWidth || 1440while
innerWidthwas 0, so a page that asked twice got two differentanswers.
On the sizes
They are a stated default, not a measurement: the engine does not expose its
own size to script, and
innerWidth,outerWidth, the client dimensions andthe layout rect all read 0. One constant now feeds
screen, the inner/outerpair, and the dimension branch of
matchMedia— the earlier arrangement hadtwo shims reading each other and recursed without bound the moment both
existed, which took the whole shim down.
definePropertyrather than assignment: the engine owns these names asread-only accessors, so a plain assignment fails silently.
Verified
A probe asserting the outcomes now reports
innerWidth=1440,(min-width: 768px|1024px|1280px)true,(max-width: 640px)false, andlocation.originresolvingnew URL('/v.json', origin)to an absolute URL.Twelve sites that previously rendered nothing now render.
Known gaps this does not close
getComputedStyleis not defined.Element.prototype.removeis undefined, though the instance method exists.firstElementChildreturns null while.childrenis populated.so page content cannot be read through it.