feat(mobile): mount the terminal document in the page over its own modules (OTA phase C, C7.5) (#21809)

* test(mobile): pin the terminal WebView document byte for byte

The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.

So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.

The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.

Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.

Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): compare two terminal documents as programs, not as bytes

The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.

It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.

So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.

Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.

Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): count each normalisation the move makes, separately

Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.

The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.

Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.

Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.

Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make the mouse-report cell a module the page can import

The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.

The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.

Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.

`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.

The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.

The document is untouched, so the byte pin from 3006d8dfdf is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make the query-reply gate a module the page can import

The second of the twelve groups, and the one that corrects the scope table's
membership rule.

`terminalDataRepliesEnabled` is written from four places, so the whole-script
census counted it among the 57 variables that cannot stay free across modules.
All four writes are in this group. Once the script is modules, a variable
written only inside the module that declares it is that module's own state, not
the document's, and it stays a `let` there. So the scope object holds what
crosses a module boundary, and the 57 is an upper bound rather than the answer;
the qualifier count the flip commit pins will be lower than the 641 measured
over the single scope, and by how much is a function of where the boundaries
fall.

Two references do cross here and are qualified: the write-queue generation this
group compares against, and the observer-disposal list it pushes onto.

Counts pinned: two qualified references, one `var` to `let`, two one-statement
`if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make reflow a module, and give the generator its own tests

The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.

The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.

Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make the keyboard-avoidance metrics a module

The fourth group, and the first that needed a non-null assertion.

`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.

Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.

Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.

The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make WebGL loss recovery a module

The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.

The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.

Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class

The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.

Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.

The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.

The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.

Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal surface-swap group into a module

The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.

Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): substitute build-time constants into the emitted document

The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.

The fixture script's TypeScript loader moves beside it rather than being
written twice.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal theme group into a module

The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.

Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.

Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal path-tap group into a module

The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.

Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.

One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.

Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal tap-dispatch group into a module

The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.

Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal mouse-click-drag group into a module

The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.

Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal url-tap group into three modules

The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.

Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.

Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.

Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the mouse-mode DECSET scan slice into a module

The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.

Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal message-bridge slice into a module

The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.

The second catch keeps its binding: it names the error and reports it.

Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the document close its own slice file

The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.

Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal term-observers slice into modules

This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.

A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.

Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the selection-state-and-eviction slice into a module

The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.

Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the smooth-scroll and cell-geometry slice

Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.

Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal write-queue slice into a module

The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`,
which belong to other concerns but sit here because emitted-document order
pins them here; four names stop being externals as a result.

The observer disposal keeps its guard-as-expression form behind a line-scoped
disable: the rewrite the rule asks for is outside every counted class.

Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10,
unbound catches 1, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal fit-scale slice into a module

The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.

Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the terminal init-and-write slice into a module

The slice opens with the already-extracted webgl-recovery group, so its text
is two emissions joined. init() resets almost every field the document shares,
which makes this the densest qualifier site in the script.

The caret options were interpolated from the theme module, so they join
`document-constants.ts` as four exports: a substitution is keyed by name, not
by property path.

One local the document declares and never reads keeps a line-scoped
`no-unused-vars` disable. Removing it would be a different program, which is
the one thing this branch does not do.

Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18,
unbound catches 7, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the runtime-state and text-scaling slice

The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.

Two things this slice forced.

The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.

The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.

Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.

Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the two test files the last commit left unformatted

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the mouse-report and scroll-routing slice

Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.

Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the host-message-router slice into modules

Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.

The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.

Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the selection-overlay slice into modules

Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.

Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the surface-touch-gestures slice into modules

The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.

The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.

Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): extract the document's opening declarations into a module

The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.

With this every line of the document's script has a module behind it.

Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the whole document script against the modules

Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:

  qualifier            609 references + 73 declarations = 682 sites
  var rebindings       373, the document's 446 declarators less those 73
  curly braces         279, the number measured before any of this started
  unbound catches      36 of 38; two name their error and report it
  Number properties    17, also measured up front
  shorthand properties 4, two SGR flags written twice each
  unshadowed names     7

A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.

The module order lives in one file that both this test and the generator read,
so neither can drift from the other.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): keep only the lint directives that do something

Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.

The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): generate the terminal document from its modules

The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.

The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:

  qualifiedReferences     609
  scopeFieldDeclarations   73
  rebindings              373
  bracedBodies            279
  unboundCatches           36
  numberProperties         17
  shorthandProperties       4
  unshadowedNames           7

Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.

Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.

Pattern changes, old -> new.

terminal-webview-scroll-routing.test.ts
  var deltaY = ts.lastY - y;                    -> const deltaY = ts.lastY - y;
  smoothScrollOffsetY -= deltaY;                -> scope.smoothScrollOffsetY -= deltaY;
  var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
                                                -> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
  'touchmove' single-quoted, one line           -> "touchmove" double-quoted, printer line break
  }, { capture: true, passive: false });        -> { capture: true, passive: false }
  function momentumStep()                       -> let momentumStep = function()
  pendingNormalScrollDeltaY += deltaY;          -> scope.pendingNormalScrollDeltaY += deltaY;
  if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
  normalScrollFrameId = requestAnimationFrame(  -> scope.normalScrollFrameId = requestAnimationFrame(
  pendingNormalScrollDeltaY = 0;                -> scope.pendingNormalScrollDeltaY = 0;
  cancelAnimationFrame(normalScrollFrameId);    -> cancelAnimationFrame(scope.normalScrollFrameId);
  var writeQueueHead = 0;                       -> scope.writeQueueHead = 0;
  writeQueueHead++;                             -> scope.writeQueueHead++;
  writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
  surface.style.transform = 'translate(' + panX  -> scope.surface.style.transform = "translate(" + scope.panX
  getVisualPanY() + 'px) scale('                -> getVisualPanY() + "px) scale("
  var FRICTION = 0.972;                         -> const FRICTION = 0.972;
  var MIN_VEL = 0.012;                          -> const MIN_VEL = 0.012;
  edgeScrollDir = dir;                          -> scope.edgeScrollDir = dir;
  term.scrollLines(edgeScrollDir);              -> scope.term.scrollLines(scope.edgeScrollDir);
  // Latching document-level touch dispatcher    -> function attachSurfaceEventHandlers(
  edgeScrollClientX = clientX;                  -> scope.edgeScrollClientX = clientX;
  edgeScrollClientY = clientY;                  -> scope.edgeScrollClientY = clientY;
  return mode !== 'none';                       -> return mode !== "none";
  var pixelX = cell.x;                          -> const pixelX = cell.x;
  var pixelY = cell.y;                          -> const pixelY = cell.y;
  ...isSafeSgrMouseCoordinate(cell.y)) return   -> ...isSafeSgrMouseCoordinate(cell.y)) {
  ...isSafeSgrMouseCoordinate(sgrRow)) return   -> ...isSafeSgrMouseCoordinate(sgrRow)) {
  if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
  if (mouseTrackingMode === 'x10') return sgrPress;   -> if (mouseTrackingMode === "x10") { return sgrPress;
  if (mouseTrackingMode === 'x10') return press;      -> if (mouseTrackingMode === "x10") { return press;
  if (col > 126 || row > 126) return '';        -> if (col > 126 || row > 126) { return "";
  document.addEventListener('touchend'          -> document.addEventListener( "touchend"
  }, { capture: true, passive: true });         -> { capture: true, passive: true }
  notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
  document.addEventListener('touchstart'        -> document.addEventListener( "touchstart"
  var clickInput = buildMouseClickInput         -> const clickInput = buildMouseClickInput
  notify({ type: 'open-url', url: tappedUrl });      -> notify({ type: "open-url", url: tappedUrl });
  notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });

terminal-webview-text-zoom.test.ts
  var CLAUDE_STATUS_DOT =                       -> scope.CLAUDE_STATUS_DOT =
  var PRIVATE_MODE_SCAN_TAIL_LIMIT              -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
  \n\n  function enqueueWrite                   -> \n  function enqueueWrite
  var terminalFontFamily =                      -> scope.terminalFontFamily =
  output = terminalFontFamily;                  -> output = scope.terminalFontFamily;
  String.fromCharCode(0x23fa)                   -> String.fromCharCode(9210)
  TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)  -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
  EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
  data.replace(CLAUDE_STATUS_DOT_PATTERN, ...)  -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
  writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
  var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")
  statusDotPendingSelector = false              -> scope.statusDotPendingSelector = false   (x2)
  term.open(surface)                            -> scope.term.open(scope.surface)
  term.unicode.activeVersion = '11'             -> scope.term.unicode.activeVersion = "11"
  enqueueWrite(ESC + '[0m' + replayData)        -> enqueueWrite(scope.ESC + "[0m" + replayData)
  fontFamily: terminalFontFamily                -> fontFamily: scope.terminalFontFamily
  fontWeight: '300'                             -> fontWeight: "300"
  fontWeightBold: '500'                         -> fontWeightBold: "500"

terminal-webview-engine.test.ts
  var webglAddon = null; .. var webglRecoveryTimer = null;
                                                -> the refreshTerminalSurface()..init( block, with the scope preamble
  window.addEventListener('resize'              -> window.addEventListener("resize"
  'terminal init failed'                        -> "terminal init failed"
  'terminal message failed'                     -> "terminal message failed"
  var everReady = false;                        -> scope.everReady = false;
  everReady = true;                             -> scope.everReady = true;
  fatal === undefined ? !everReady : !!fatal    -> fatal === void 0 ? !scope.everReady : !!fatal
  msg.type === 'init' && !everReady             -> msg.type === "init" && !scope.everReady
  /fatal === undefined \? !ready\b/             -> /fatal === void 0 \? !scope\.ready\b/
  if (msg.type === 'ping')                      -> if (msg.type === "ping")
  notify({ type: 'pong', pingId: msg.id })      -> notify({ type: "pong", pingId: msg.id })

terminal-webview-reflow.test.ts
  } else if (msg.type === 'reflow') {           -> } else if (msg.type === "reflow") {   (x2)
  var MIN_FIT_COLS = 20;                        -> scope.MIN_FIT_COLS = 20;
  if (cols < MIN_FIT_COLS) return;              -> if (cols < scope.MIN_FIT_COLS) {
  flog('measure-skip-small-width'               -> flog("measure-skip-small-width"
  notify({ type: 'measure-result', ... })       -> notify({ type: "measure-result", ... })
  var dispatch = { mode: 'idle'                 -> const dispatch = { mode: "idle"
  window.addEventListener('message'             -> window.addEventListener("message"

terminal-keyboard-avoidance-webview.test.ts
  \n  // reflow()                               -> \n  function reflow(
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")
  \n  var panX                                  -> \n  scope.panX
  TERMINAL_REFLOW_JS fragment import            -> the reflow(cols, rows)..notify( slice of the document

terminal-webview-query-reply.test.ts
  attachTerminalQueryReplyBridge(term, gen)     -> attachTerminalQueryReplyBridge(scope.term, gen)   (x2)
  term.attachCustomKeyEventHandler(function() { return false; })
                                                -> term.attachCustomKeyEventHandler(function() { \n return false; \n });
  term.textarea.readOnly = true                 -> term.textarea.readOnly = true;
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")

terminal-webview-url-tap.test.ts
  notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });

terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): delete the slice constants and injected fragments

The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.

Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.

Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.

Moved, with every assertion kept and the `expect` count per file unchanged:

  terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts   34
  terminal-webview-theme-injected.test.ts   -> terminal-webview-theme.test.ts 14
  terminal-webview-query-reply.test.ts                                        14
  terminal-path-tap.test.ts                                                   25
  terminal-webview-url-tap.test.ts                                            33
  terminal-keyboard-avoidance-webview.test.ts                                 18
  terminal-webview-reflow.test.ts                                             22
  terminal-webview-text-zoom.test.ts                                          59
  terminal-webview-engine.test.ts                                             49

Pattern changes, old -> new.

terminal-webview-reflow.test.ts
  if (!term || isAlternateBufferActive()) return;
                        -> if (!scope.term || isAlternateBufferActive()) {
  term.resize(nextCols, nextRows);        -> scope.term.resize(nextCols, nextRows);
  var wasAtBottom = buffer.viewportY >= buffer.baseY;
                        -> const wasAtBottom = buffer.viewportY >= buffer.baseY;
  term.scrollToBottom();                  -> scope.term.scrollToBottom();
  if (nextCols === term.cols && nextRows === term.rows) return;
                        -> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {

The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.

Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.

  query-reply             terminalDataRepliesEnabled = true       -> query-reply test, 2 failed
  path-tap                const parsed = parsePathLineCol(...)    -> path-tap test, red
  keyboard-avoidance-metrics  contentBottomRow                    -> keyboard-avoidance test, 4 failed
  reflow                  scope.term.resize(nextCols, nextRows)   -> reflow test, 2 failed
  webgl-recovery          new window.WebglAddon.WebglAddon()      -> engine and text-zoom tests, 4 failed
  osc-link-tap            return parsePathLineCol(value)          -> url-tap test, 1 failed
  terminal-theme          scope.term.options.minimumContrastRatio = ...
                                                                  -> theme test, 4 failed
  write-queue             scope.writeQueue[scope.writeQueueHead] = undefined
                                                                  -> write-queue test, 4 failed

`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name the right number of counted classes

The flip test's title still said seven; the table it asserts has eight.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the shape applyTerminalTheme writes through

The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.

No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.

Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): retire the flip pin, leaving the byte golden as the document's fence

`terminal-document-flip.test.ts` compared the emitted modules against
`terminal-document-pre-flip-script.txt`, the hand-written script as it stood before
C7.1, and held exactly while no module changed. That is the proof of the flip, not a
standing fence: the first lane that must change a module has to retire it or restate
its counted classes for a reason that has nothing to do with the move.

C7.5 is that lane — the document's host seams become scope fields so the page can set
them — so both go here, while the test is still green. The flip proof lives at
51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where
anyone reviewing the move should read it.

From here the standing pin is the whole-document byte golden,
`terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by
the payload-hash digest beside it. Regenerating it is a review event: the emitted diff
is listed old to new in the commit message and in the PR body, and a golden that moves
without a listed diff is a blocking finding.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the terminal document's host seams a field on its scope

Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a
terminal `notify` through it would post raw terminal JSON into the bridge's channel,
and there is no engine IIFE hanging `Terminal` and the two addons off `window` because
the page imports xterm. Four reads had to become seams:

  host-notify.ts      notify()             -> scope.postToHost
  viewport-transform  flog()               -> scope.postToHost
  terminal-init.ts    new Terminal(...)    -> scope.createTerminal
  terminal-init.ts    window.Unicode11Addon-> scope.createUnicode11Addon
  webgl-recovery.ts   window.WebglAddon    -> scope.createWebglAddon

Each default is the window read the site already did, still performed at call time and
not captured when the scope is built, so inside the WebView the program is the one it
was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object,
because the scope's defaults are those functions and the factory runs as the script is
parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's
file, which the four fields pushed over the 300-line cap; document-scope re-exports
them, so no importer moves. The page's side of the seam lands in C7.5's later commits.

Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside
the `try` it was built in, so a constructor that throws is still swallowed; and no
WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`,
which is the DOM-renderer fallback rather than a failure.

Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document
723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else:

  + (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }
  + (new) function createEngineTerminal(options) { return new Terminal(options); }
  + (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; }
  + (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; }
  - "      pendingTerm: null"
  + "      pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon
  - flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }"
  + flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });"
  - "    if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {"
  + "    if (!scope.term) {"
  - "      addon = new window.WebglAddon.WebglAddon();"
  + "      addon = scope.createWebglAddon();" then "      if (!addon) {" / "        return false;" / "      }"
  - "    scope.term = new Terminal({"
  + "    scope.term = scope.createTerminal({"
  - "    if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / "      try {" / "        scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / "      } catch {"
  + "    try {" / "      const unicodeAddon = scope.createUnicode11Addon();" / "      if (unicodeAddon) {" / "        scope.term.loadAddon(unicodeAddon);" / "    } catch {"
  - notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }"
  + "    scope.postToHost(msg);"

Nothing else in the document moved: the emitted indentation, statement order and every
other literal are byte for byte what they were.

Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the
new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions
and aimed them where the text now is: `window.WebglAddon.WebglAddon` and
`new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on
the recovery module, and the recovery module is asserted to call
`scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope
before the globals exist to show the defaults read the window when they post, shows
each addon factory answering null when the engine has none, and drives a host message
in and a notify out with all four fields set, asserting the bridge is never touched.
Red before this commit at 6 of 7 cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): write the xterm stylesheet as its own generated artifact

The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.

`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.

The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.

The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.

`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards

Round 1 fixes, all three folded here.

1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
   `document/url-tap.test.ts` deleted alongside it. The document is generated
   from its modules now, so that constant was a second copy of the URL-tap group
   with no consumer but its own tests. terminal-webview-url-tap.test.ts's
   resolver harness reads the document's own text instead, the path-tap,
   url-tap, osc-link-tap and surface-tap modules in document order through
   `generatedDocumentModule`, which refuses unless the document carries each
   verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
   file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
   pin of the three emissions against the constant, which the flip test's
   whole-document pin already covers. The file's other exports stay.

   The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
   concatenated terminal-webview-url-tap.ts into its `source`, and its
   `notify({ type: 'terminal-tap' });` assertion was matching the constant's
   single-quoted text, not the document. The read is dropped, since nothing else
   in that file needed it, and the assertion is the document's form:

     notify({ type: 'terminal-tap' });  ->  notify({ type: "terminal-tap" });

   Its 95 expects stay. Leaving the read in place would let a document assertion
   pass against a module source, which is the hazard this lane exists to remove.

2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
   exists, so it could not fail:

     expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
       ->  expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)

   Same intent against the generated document: the reflow module's emitted text
   is in the document exactly once. The case is renamed to say so and the
   comment above it describes the generator, not the deleted template.

3. Same file, the routine assertion still passed as a substring of the qualified
   call; qualified as line 30 already was:

     term.resize(nextCols, nextRows);  ->  scope.term.resize(nextCols, nextRows);

   Its 22 expects stay.

Controls, each verified to have changed the file first, all red, tree green
after restore:

  osc-link-tap  return parsePathLineCol(value)        -> url-tap test, 3 failed
  surface-tap   notify({ type: 'terminal-tap' })      -> scroll-routing, 1 failed
  reflow        scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
  module order  'reflow' listed twice                 -> reflow test, expected 2 to be 1

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): mount the terminal document in the page instead of a WebView

`react-native-webview` has no web build that renders anything: measured, it paints the
line "React Native WebView does not support this platform" where the terminal was. So
the page mounts the document itself — xterm imported from `@xterm/xterm` with the
unicode11 and webgl addons, and the document's own modules imported in the order the
generator emits them — behind the identical `TerminalWebViewProps` and
`TerminalWebViewHandle`.

Written as one implementation, not two. `use-terminal-webview-controller.ts` is
everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the
readiness handshake, the pending queue, the write coalescer, the notify dispatch and
the whole imperative handle. Its two arguments are the difference between the hosts —
a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to
re-prove the document with a ping. The native component posts across the bridge and
answers yes on iOS; the web component calls `handleMsg` and answers no, because its
document is the page's own modules and there is no second content process to lose. A
second copy of that file is the fork the series exists to avoid, since the handle is
the contract every consumer holds.

`terminal-webview-ready-promises.ts` carries the two promises the handle hands out,
`awaitReady` and `measureFitDimensions`, which the controller's length made a module.
`document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out
of the document shell; the shell composes them and the golden is byte for byte
unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the
caret options and nothing else, so the page resolves no document string and no engine
string.

`terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the
stylesheet and the markup, sets the four scope seams, and reaches the modules by one
dynamic import — they read their elements as they are parsed, so a static import would
hoist above the planting and leave every one of them holding null.
`page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19
because on the page those `message` frames belong to the shell; its one non-bridge
duty, the window-resize refit, is re-armed by the mount.
`page-document-module-order.test.ts` holds that list against the generator's own,
so a sorted import list or a module added on one side cannot pass.

Two page-side degradations, both bounded and both stated. The document assigns
`window.onerror` as it is parsed, so while a terminal is mounted page errors reach its
reporter; the mount restores the previous handler on dispose. And a browser that
refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery`
already has for a context loss, with a `[fit]webgl-unavailable` notify saying so
rather than a silent halving of the drain rate.

`terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it
scans `src/session` and the terminal directory for an import of the component file by
name, of `terminal-webview-html`, of either generated engine module or of anything
under `document/`, finds none outside the component and its mount, and shows on
planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs`
gains the component's own closure: `TerminalWebView.web.tsx` and
`terminal-webview-html.web.ts` are in it, the engine string, the native HTML module
and `message-bridge` are not.

Four source greps follow the code into its new home, every assertion kept:
`terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the
controller, and reads the two lifecycle clears once in `resetReadiness` plus both
WebView callers in the component; `terminal-webview-reflow` and
`terminal-webview-scroll-routing` read the handle in the controller and the two timers
in the promises module (`measureResolveRef.current === finish` -> `measureResolve ===
finish`, `void p.finally` -> `void pending.finally`).

One behaviour was nearly lost and is pinned by an existing case: the native
foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render,
so the transport asks a predicate rather than carrying a boolean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): render the page's terminal in a browser under the shell's policy

Everything below the contract is new on the page: xterm is an import rather than a
612 KiB string in a WebView document, the document's modules run in the page's own
realm, and the elements they read by id are planted by the component. No module test
settles whether that opens at all under `script-src 'self'` with neither
`unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the
buffer intact.

Three cases in the C6 render harness, against the bundle built by the real builder and
served under the policy parsed out of the shell's own Kotlin constant.

The stream is built for the grid rather than committed: an SGR colour change per cell,
an erase-to-end and an absolute cursor position per row, run out past the host's own
48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the
document's own path — select all, then the Copy button the overlay carries — so the
oracle is the component's `onSelectionCopy` prop and not a private reach into xterm:
6,133 characters, both edge markers present, and no escape byte or SGR text left in
them, which is what says the parser consumed the stream instead of printing it.

The second case takes a fit through the handle, which on the page is a command in and
a notify back with no bridge between, and carries design §8's cheap half of the IME
question. It first pins something that changes where that probe can even point:
xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
read-only, untabbable and `inputmode=none` so touch and hardware keys go to the
screen's input — so text entering a terminal on the page arrives at a `TextInput`, and
that is what is typed into. Chrome reports `insertText` with `isComposing` false for
each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft
keyboard is the device step and this does not claim to answer it.

CSP violations are counted with a `securitypolicyviolation` listener installed before
anything else runs, which is stricter than the console-error filter the other render
checks use — and the first thing it found was not the terminal's. The page entry
carries Zod, whose `new Function` probe is swallowed by its own catch, so
`script-src: eval` is refused once on any page route with no page error and no console
line. The first case is the control that names it, on a route that mounts a marker and
no terminal; the two terminal cases subtract it and report zero of their own. Zero
page errors and zero console errors besides.

No route serves this screen until C7.7, so the component is bundled through a scratch
route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx`
exactly as a real route would. That step retires when the session route is registered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): retire the last module concatenator and guard the order list

Round 2 fixes, all five folded here.

1. Deleted terminal-webview-html-source.test-support.ts.
   `readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
   tests moved to the generated document, and it was the last thing that built a
   document-shaped string by concatenating module sources — its filter admitted
   `.test-support.ts` files too, so it could have grown one. Confirmed by grep
   that the only occurrence of either name in the repository was its own
   declaration.

2. New document-module-order.test.ts asserts both directions: the non-test,
   non-test-support `.ts` files under `document/` are exactly
   `{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
   and no name is listed twice. `document-constants` is the one exception
   because it is never emitted: its exports are substituted into the modules
   that import them as literals, so the document carries its values without
   carrying the module. A module added here and forgotten there would be dead
   code that reads as live; a name left after its file goes makes the generator
   throw at build time rather than at review time.

3. terminal-document-flip.test.ts's docstring now carries the retirement policy
   from ruling 18: the test is the proof of the flip and holds only while no
   module changes, the first lane that must change one retires it together with
   `terminal-document-pre-flip-script.txt`, and the standing pin from then on is
   `terminal-document-identity.test.ts`, whose fixture regeneration is a review
   event. Comment only.

4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
   and "Four classes and no others". It now says 73 declaration sites and eight
   classes, with each class's measured figure named. Two doc comments sat above
   the wrong declaration and were moved onto what they describe: the
   `NUMBER_GLOBALS` one down to that constant, and the printing one down to
   `significantTokens`, with `STRICT_DIRECTIVE` given its own line.

5. build-terminal-document-script.mjs substituted constants with
   `replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
   constant's value are read as replacement patterns. The substitution is now
   `substituteDocumentConstants`, exported so it can be tested directly, and
   replaces with a function.

Controls, each verified to have changed its input first, all red, tree green
after restore:

  plant document/zz-planted-module.ts   -> order guard, "+ zz-planted-module"
  drop 'wheel-scroll' from the order    -> order guard, "+ wheel-scroll"
  revert to the string replacer         -> 4 failed, "a $& b" became "a marker b"

The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.

The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): measure what the page terminal costs the session route's closure

The session route is not served on the page until C7.7, but the closure the bundler
would walk is the same one and the terminal is the largest thing in it. Measured
against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03:

  modules         4316 -> 4363        (+47)
  local modules    927 ->  971        (+44)
  minified bytes   3,930,787 -> 3,883,532   (-47,255)

The route gets smaller. It sheds six modules — the native component, the 612 KiB
engine string, the 105 KiB generated document script, the HTML module and the shell
and close around it — all string literals of a program the page cannot run, and gains
fifty: the component, its mount, the stylesheet and markup modules, the two the
controller split made, and the document's own thirty-nine, with xterm and the two
addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts`
is not among them: it declares types and esbuild emits nothing for it.

The census pins the trade in both directions, because "the engine string is absent"
passes just as well on a closure that resolved nothing: the six shed modules are
asserted gone, the eight gained ones and the three xterm packages asserted present,
and the document asserted whole except `message-bridge`, which ruling 19 keeps off the
page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved
styles — since the terminal's modules joining this closure is exactly the change that
could add a tenth unread.

The page-closure families were run before and after on the full corpus, never a
filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879
include the four page-closure pins, which assert the verdict of every golden C1, C2,
C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an
unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed
and 773 skipped. No family moved, which is what an inert lane should show: this
branch changes no RPC, no opcode, no grant and nothing the recorder reads.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): clear the changed-code gate findings this lane introduced

Eleven findings from `check-changed-code-quality.mjs` against the base, all in code
this lane added, none of them a behaviour change.

Two type assertions lost their directive to the formatter. The xterm `Terminal` cast
sits on the second line of a wrapped arrow body, so a directive above the assignment
aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon
cast had no directive at all. Both keep the same `SAFETY:` rationale on one line,
which is the only shape oxlint reads.

Two more assertions in `host-seams.test.ts` are gone rather than annotated. The
terminal double's `element` is a getter over a local the double's own `open` writes,
and `withSeams` reads each field it is about to overwrite through
`getOwnPropertyDescriptor` instead of indexing the scope with a cast.

Then three `eslint-disable no-console` directives that disabled nothing, an
`oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the
reason it carried stays as a comment, since the dependency list is still deliberate —
and one duplicated `node:fs/promises` import.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(config): name the closure helper what main already named it

A trial merge against `origin/main` conflicts on this function: main grew the same
generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with
`mobileWebAppRouteClosure` delegating to it and three callers in the page-closure
families census. This branch is based on `ota-c7-1-terminal-document` and so cannot
merge main, but it can stop being a second spelling of the same thing.

Taken over wholesale: main's name, its parameter, its extension stripping and its
comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main
already has. The only addition is an options bag carrying `absWorkingDir`, which the
engine-closure census needs to plant a module in a tree of its own and show the walk
would report it; the real measurements never pass it. What was a whole-function
conflict is now that one hunk.

The census case that measured the native document had named
`terminal-webview-html.ts` with its extension, which main's stripping does not allow.
It names `terminal-webview-html/document-shell` instead — the module that actually
reads both generated ones — which is the better probe anyway and needs no extension
to resolve, since it has no `.web` sibling.

`web-overrides.json` also conflicts and is left alone: both sides append entries to
one list and the resolution is mechanical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(config): put the two closure helpers in main's order

The previous commit took main's name and signature but left the route closure below
the module closure, where this branch had written it. Git merged both orderings and
produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint
reports as a duplicated export — a red the trial merge found and neither side's own
lint could.

Same order as main now: the route closure and its docstring first, the module closure
under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the
merged tree lints clean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make the flip comparator refuse what it was accepting

Round 2 items 6 and 7, both in the equivalence instrument.

6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
   the two were the same binding, so an unrelated rename ending in a digit would
   have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
   an explicit list of pre-flip name, generated name and declaring module. The
   whole script has one entry: `term2` -> `term` in `query-reply`, which is the
   `term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
   sites in all. That is stated in the docstring rather than encoded as a second
   pin, since the flip test already pins the total.

7. Brace absorption treated every unexpected `{` as a linter-added body and
   absorbed any later `}` while one was outstanding, so a bare block anywhere
   would have been swallowed. `isBraceableHeadBody` now requires the open to be
   the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
   `(` and reading the keyword before it — and `matchingCloseIndex` records the
   index the close must appear at, so the absorbed `}` is that body's own.

   That check had to move ahead of the equality check. Wherever a braced body
   ends a block, the baseline's next token is a `}` as well, so pairing them
   would consume the wrong one and leave the counts right for the wrong reason.

Both refusals are tested over snippets:

  function f() { return value2; }  vs  return value;
    -> token 6: expected name value2, generated name value
  let value = 1; use(value);       vs  { let value = 1; } use(value);
    -> token 0: expected name let, generated {

and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.

Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.

The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.

Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(config): take main's docstrings for the two closure helpers

The order matched but the prose did not, so the trial merge still conflicted on the
whole block. Both docstrings are now main's own text, with one sentence trimmed: main
names `MobileBrowserPane` as the first component with a pin of its own, which is C6's
fact and not one this branch can assert.

What remains between this branch and main in this file is the `absWorkingDir`
parameter, which is what the engine-closure census plants a module with.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page terminal's notify sink in an effect, not during render

React Doctor's one error on this branch, and a real one: `receiveRef.current = receive`
ran during render. React may replay or discard render work, so a mutation made there
can leak from UI that never commits — and this ref is read from a callback the mounted
document keeps, which outlives the render that installed it.

Moved into its own effect, declared above the mount effect so the first read already
sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0.

Found late because the first run of that gate was read through `| tail`, which reports
the pipeline's last command rather than the gate's own exit code.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): teach C7.1's order guard the three modules this lane added

The guard C7.1 landed says the document directory and the order list name the same
modules. On this branch three files are in that directory and not in that list, so it
was red on the merge — which is the guard working, and the fix is to name each of them
with its reason rather than to loosen the scan.

  document-host-seams    emitted, but ahead of the scope rather than inside the order
                         list, because the scope's defaults are its four functions and
                         the factory runs as the script is parsed
  document-terminal-shape  types only; esbuild emits nothing and an empty emission
                         would add a blank line to the document
  page-document-modules  the page's entry, not the WebView's, holding the same order
                         for a host that has no generator to splice them

Named one by one, not filtered by a pattern, so a fourth cannot join them by looking
similar. A third case asserts the seams module is neither in the order list nor the
scope module, which is the ordering the first two cannot see.

Red before this commit: C7.1's version of the file on this tree reports
`document-host-seams` and the other two as directory modules the list does not name.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): re-measure the session closure against the merged C7.1 base

Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved
from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this
lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`,
a module that enters this closure only once the page's component reaches it, so the
saving shows on the after side and cannot show on the base. Both readings are recorded
with the commit each was taken against, because a number with one base named and
another used is the kind of thing a reviewer cannot check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): retire the flip comparator with the pin it was built for

The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts`
went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts`
went in this lane's first commit under ruling 18, because the flip pin holds only
while no module changes and C7.5 is the lane that changes them. What was left was a
tool, its token reader and a test of the tool, answering to nothing.

So `terminal-document-equivalence.test-support.ts`, the
`terminal-document-tokens.test-support.ts` C7.1 split out of it, and
`terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on
the comparator — bounding an absorbed body to one statement, and refusing a bare block
as `use();` against `{ use(); }` — since there is no comparator left to tighten. The
standing pin on the document is the whole-document byte golden, which is a stronger
claim than token equivalence ever was: it admits no normalisation at all.

`document-module-order.test.ts` gains the case its exception list was asserting in
prose. `document-terminal-shape` is not in the order list because esbuild erases a
module of type declarations to the empty string, and emitting it would put a blank
line in the document rather than a program; that emission is now measured and pinned
as `''`. If the module ever declares a value the case goes red and the module belongs
in the order list with its own line in the golden diff.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): make the document's error reporter the sixth host seam

Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which
inside the WebView is taking nothing from anyone — that document owns its page — and
on the page is a guest displacing whatever the host installed. Restoring it on dispose
was a patch over the takeover, not an answer to it: while a terminal was mounted, every
page error still went to the terminal's reporter.

So `scope.installErrorReporter` joins the five, with today's assignment as its default.
`host-notify` hands it the same handler it always installed, and the WebView's document
is the program it was.

The page supplies its own: an `error` listener that adapts the event to the reporter's
arguments, added on mount and removed on dispose, and `window.onerror` is never
written. This one seam is *called* as the modules are parsed rather than later, so the
mount now reaches `document-scope` on its own first and sets every field before a
single document module runs — which is also the safer order for the other five.

Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines
out, seven in, and nowhere else:

  + (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; }
  - "      createWebglAddon: createEngineWebglAddon"
  + "      createWebglAddon: createEngineWebglAddon," and "      installErrorReporter: installWindowErrorReporter"
  - "  window.onerror = function(msg, source, line, column, err) {"
  + "  scope.installErrorReporter(function(msg, source, line, column, err) {"
  - "  };"
  + "  });"

`terminal-webview-payload-hash.test.ts` takes the new length and digest.

Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror`
and a host that installs its reporter elsewhere leaving it null. The render check adds
a browser case: `window.onerror` is null before the mount, null after it, and null
after the component unmounts — with a real uncaught error thrown in between and
asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that
had simply stopped reporting, and a second error after dispose asserted to reach
nothing. Red with the mount's override removed: `expected undefined to be null`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): empty the session closure's react-native-webview list

C7.6's census on main names the terminal as the last consumer and says whose work it
is: "The terminal is the third and is C7.5's, which drops the engine string and mounts
xterm in the document". This is that lane, so the list it left is now empty and the
session closure reaches `react-native-webview` from nothing at all.

Emptying a list weakens the case that reads it, because an empty result is also what a
scan that read no file reports, so two things change with it. The main case gains its
preconditions: the walk read a closure of more than 500 local modules, and it read the
three web siblings whose native halves are exactly the modules that would have
imported the package. And the control stops walking the list — with the list empty that
compared nothing against nothing — and walks the three native files instead, which do
import it, alongside the three web siblings, which do not.

`TerminalWebView.web.tsx` joins the answered list, so the case that the builder
resolves a web sibling rather than its native file now covers all three.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): pin the onerror seam against a handler the page actually owns

The case read `null` before the mount, while mounted and after dispose. That is true
but weak: a terminal that assigned `null` over a real handler would pass it, which is
exactly the takeover ruling 19 forbids.

So the page now installs a handler of its own in an init script, before the bundle
loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`,
compared inside the page because a function does not survive `evaluate` — at all three
points. Between them an uncaught error is thrown and both reporters are asserted to
see it: the page keeps the handler it installed, and the terminal's own listener still
works, so the readings cannot pass on a terminal that had simply stopped reporting.
After dispose a second error reaches the page's handler and not the terminal's, which
is what taking the listener off has to mean.

The `null` reading stays as its own case, because the other half matters too: on a page
that installed nothing the terminal must not leave a handler behind for the next
consumer to find.

Both go red with the mount's `installErrorReporter` override removed — `expected false
to be true` and `expected undefined to be null`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start the terminal document per mount (ruling 20)

Round 1's blocking finding: ES module bodies run once per page, so the page's
second mount re-imported nothing and inherited the first mount's elements,
listeners and error reporter. Measured after a remount: zero .xterm nodes in
the live DOM, no selection overlay, nothing reaching onEngineError, and
onWebReady still firing.

Ruling 20: no emitted module does work as it is parsed. Every top-level effect
moved into an exported per-module start function — 86 statements across 14
modules, plus three parse-time captures whose declarations became typed lets.
The generator emits one call sequence in module order at the foot of the
document, so the native script still runs them once at parse; the page runs the
same sequence per mount and dispose undoes the three that outlive the host
element (tap-dispatch, webgl-recovery, host-notify).

installErrorReporter now hands back its own undo, so it stays five seams at six
document sites rather than growing a sixth.

M2: a failed document chunk was an unhandled rejection with no engine error.
It now goes down the document's own reporting path, so the overlay names the
cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at
the wire in the render check.

L3: the seam count now reads five fields / six sites / three files everywhere.
L4: three unrelated web-overrides entries keep main's escaping.

Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256
2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the parse-time census tree without assertions

The changed-code gate refuses type assertions. The walker reached node fields
through `as Record<string, unknown>`; it now reads them with Object.entries,
which is checked and says the same thing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): move the document's state onto the scope (ruling 21)

Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.

Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.

Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.

Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.

L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.

Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse frames from a stopped document

The frame case went red under full-suite load: tearing the terminal down runs
the engine's own disposal, which calls back into these modules, and a frame
asked for on the way out was owed by nobody because the cancel had already run.
A stopped document now asks for no frames at all, so the ordering inside
dispose stops mattering.

The render case is also rewritten around the work that survives a loaded
machine. It gives the terminal a scrollback and sends one wheel, which reveals
the scroll indicator and arms the 550 ms timer to hide it again, and the
boundary between the two mounts is drawn when the first terminal leaves the
page rather than when the component is told to go — React unmounts on its own
schedule, and a callback that runs while the first terminal is still up is not
a leak. The precondition counts what the document scheduled under the first
mount, so an empty leak list cannot mean the wheel reached nothing.

Verified both ways at this head: red with stopViewportTransform and
cancelDocumentFrames removed, green with them, and green in the whole
config/scripts suite.

Payload 726081 -> 726195, sha256
67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): style only what the page mount owns

CodeRabbit on document-style.ts:16. The mount appended the document's whole
stylesheet to the page head, so its `*`, `html` and `body` rules restyled every
screen the shell can show and went on doing it after unmount. Ruling 19's
shape: the native document owns its page and keeps the sheet as it is; the page
mount may style only what it owns.

The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and
TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted
document does not move for the split - verified byte-identical before the seam
below. The page injects the element half only, with every selector held under
the host's own class, and xterm's sheet goes through the same rewrite. The
rewrite refuses an at-rule rather than passing its inner selectors through
unscoped.

A second leak of the same kind was in the same measurement: applyTerminalTheme
wrote the terminal background straight onto `html` and `body`. That is a sixth
seam - six fields at seven document sites now. Its default does exactly the two
writes it did; the page paints the host element instead. Emitted lines, old to
new: `paintWindowDocumentBackground` added beside the other defaults (3 lines);
`paintDocumentBackground: paintWindowDocumentBackground` added to the seam
factory (1 line); in applyTerminalTheme, the two `document...style.background`
writes become one `scope.paintDocumentBackground(background)`.

Leaving the sheet in the head after unmount is kept, and is now defensible: the
host drops the class on dispose, so every rule in it matches nothing until the
next mount.

The render check gains a case comparing `body` and `html` computed styles,
while mounted and after dispose, against a page of the same application with no
terminal on it, and asserting no rule of the injected sheet matches an element
outside the host. Verified red both ways at this head: unscoped sheet moves
`background-color` and `box-sizing`, and the inline theme write moves
`background-color`.

Payload 726195 -> 726363, sha256
9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the page mount's rules and frames their own oracles

Round 3 blocks on evidence, not on shipped behaviour. Each item:

H1. The scoping had no positive oracle: dropping the host class, or injecting
an empty xterm sheet, left the render check green, because every assertion was
about rules not escaping. The containment case now also reads four things off
the live elements under the host — xterm's own `position: relative`, the
viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width,
and the overlay's `position: fixed`. Red both ways: no host class reds all
four, an empty engine sheet reds the first.

H3. `cancelDocumentFrames` had no witness: the only leak the timer case could
see was the 550 ms hide timer, which its own module's stop cancels. There is
now a case whose witness is a frame taken through `scheduleDocumentFrame` —
the fit retry loop, with the surface hidden so the fit never commits and one
frame is always owed at dispose — and it reds when only `cancelDocumentFrames`
is removed. A unit covers the registry itself: a frame is held until it runs,
a cancel takes back every pending one and then refuses to schedule, and a reset
re-enables it.

The two scheduling cases now assert on their own witness kind, so neither can
stand in for the other, and the recorder judges a leak by whether the
`#terminal-container` that was on the page at schedule time is still in the
document — React unmounts on its own schedule, and a callback that runs while
the first terminal is still up is not a leak. The timer witness moved from the
scroll-indicator timer to the long-press timer, because the first needed a
drained scrollback and raced the engine under load; its precondition caught
that rather than passing.

L1. The two seam docstrings each sit on their own function.
L2. The parse-time census plants an element-read initialiser, which the
statement filter cannot see, and an inert object literal, which a reader that
flagged every initialiser would wrongly report.
L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that
never committed leaves two terminals and only one was reached. Deduplicated,
because they are the same object whenever no swap is open, and pinned both ways.
L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT.

Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render
check: 12 cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the page document's dispose idempotent and owner-checked

CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A
handle outlives what it built - the component keeps one in a ref and React can
run a cleanup after a later mount has started - and everything dispose touches
is shared: the scope, the module sequences, window.__engineErrors. So a second
call, or a call from a handle whose document had already been replaced, tore
down the terminal that was on the screen and handed the page away while it was
still in use.

Each mount now carries a token, and dispose acts only when that token is still
the live one. A token rather than the host element or its class: two mounts can
be handed the same element, because the page remounts into a host React has
reused, so an element is not an identity and the class says only that some
document is using the host. The failed-mount path releases the page under the
same check.

Pinned both ways, red with the check removed: disposing twice leaves a terminal
put back after the first teardown alone, and a stale handle disposed after a
second document mounted changes nothing - the live markup stays, its terminal
is not disposed, and the page is still refused to a third mount.

Golden unchanged at 108,329 bytes; payload and hash unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let a pending page mount be disposed before its import lands

Round 4 on #21809.

H1. The mount claimed the page before its dynamic import and handed back a
promise, so a component cleanup that ran while the chunk was still in flight had
nothing to dispose: the claim outlived the mount it was made for, and Reload —
the recovery ruling 20 names — was refused as a second document. The claim, the
markup and the handle are now made synchronously, `ready` settles on its own,
and a mount disposed while its import was in flight releases without starting
anything. Pinned in the render check by holding the document chunk 20 s past the
15 s readiness watchdog, clicking Reload and waiting for the second mount to
become live; red at that wait before the change.

M1. The frame case's precondition asserted that a frame had been asked for while
the document owned the page, not that one was owed when it was disposed. The fit
retry commits on its first attempt whenever the grid still measures, so a dispose
between two refits owed nothing and agreed with an empty leak list for exactly
the reason under test — one run in five. The refit and the unmount now share one
discrete click, which React flushes before the event returns, and a mutation
observer reads the registry at the instant the host is emptied. Five red runs
without `cancelDocumentFrames`, all on the leak and none on the precondition,
and five green with it.

M2. Two mounts handed the same element, which is what the token is for: the
other six cases use a different element each, so a host comparison passes all of
them.

L1. A throw inside the start sequence released the token but ran no stop, leaving
the host-notify error listener installed until the next reset nulled its undo.
The sequence now unwinds the starts that completed, in reverse, before it
rethrows.

L2. A render case comparing the window and document listeners the page holds
with no terminal on it, before and after a mount, so a stop that forgets one is
a failure rather than a second copy per terminal ever shown.

L4. Separated the stacked docstrings in the parse-time-effects census.

The render check's bundle, server, browser and page helpers move to their own
fixture module: the cases are what is under review and the scratch route tree is
not, and the file was 16 code lines under its cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): count the page document's leaked frames from dispose, not from detach

CI's addendum to round 4's M1: the frame case failed with the fix present,
`expected [ Array(1) ] to deeply equal []`, on a slower runner.

What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every
other frame the document asks for — the document has no other rAF call site. It
is not an escape from the registry, so the registry is not what changes here.

Why it was counted: React unmounts in two steps. The mutation phase detaches the
host, and the passive cleanup that calls `dispose` runs after it — about 1 ms
later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner
shape this failed on. A frame served in that gap runs with a detached container
while the document is still live and has not been asked to stop, and nothing
could have taken it back: `cancelDocumentFrames` had not been called yet. The
oracle judged by the captured container's connectedness, so it read the gap as a
leak. It now counts only what runs after the last statement of `dispose`, which
is the class coming off the host, observed on the element because React may have
detached it already.

The same reading fixes the other direction. The precondition is read at that
same moment, and the witness is a refit re-armed from a frame of the test's own,
so the document is owed a frame at the end of every frame the browser serves and
a dispose cannot land where nothing is owed. The single refit the case used
before bought one frame, and the retry loop commits on its first attempt
whenever the grid still measures.

Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two
runs of three unthrottled, and in five of five with the CPU throttled 20x, where
the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with
`cancelDocumentFrames` removed, five red runs, all on the leak read and none on
the precondition.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop a page mount that lost its claim before it writes the scope

Round 5 on #21809.

F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await
import(...)` the whole body ran whatever had happened in the meantime: it
overwrote the six seams, called `startPageDocumentModules` and added the resize
listener, and only then did the caller's `.then` read the claim and throw the
result away. Everything after that await is shared — the seams are fields on a
module-singleton scope, and the start sequence resets that scope and installs
the document's listeners — so a mount disposed while its chunk was in flight was
writing over a mount that owns the page. The claim is now re-read the instant
the import lands, before any of it, and the build returns null.

`ready` for such a mount resolves rather than rejecting. Nothing failed: the
caller asked for the terminal and then asked for it to go away, and the chunk
arriving afterwards is not something for the error overlay to name. Before this
it rejected with a TypeError from `startSelectionMenuButtons` reaching for an
emptied host.

F2. The rejection handler called `release()` unconditionally, emptying a host the
mount may no longer own. It now releases only when the page is still its own.

Pins, both red first. In happy-dom: mount, dispose, then await ready — no
listener, timer or frame added while it resolves, the six seams unchanged,
`terminalGeneration` unmoved because the start sequence never ran, and the page
free for the next mount. Without the fix that case rejects with the
`startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight
case now reads the page's listeners with no terminal on it and compares them
against a page that mounted once and disposed once; without the fix the
abandoned mount leaves `window error` and `window resize` behind, because the
second mount's scope reset nulls the first mount's reporter undo.

The listener snapshot helper is shared with the mount-and-dispose case rather
than written twice.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(config): give the render fixture's server and scratch tree back when it cannot start

CodeRabbit on the render fixture, plus its note on `release`.

The fixture. `chromium.launch` is the last step of the setup and the one that
fails in practice — no Chromium on the machine, an
`ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle
server is listening and the scratch tree is on disk. Rejecting there left the
caller without a handle, so `afterAll` had nothing to close and both stayed
allocated; the listening socket is the one that bites, because an open server
handle keeps the vitest worker alive after its last test has reported. The setup
after `mkdtemp` is now wrapped, gives back whatever it managed to take, and
rethrows the original error rather than anything the cleanup raised. The normal
close path awaits the server-close callback instead of firing it.

`release` in the page mount. The ownership check covered the claim but not the
two lines that make the terminal disappear, so a release that skipped the claim
would still empty the host and drop its class. The check now guards the whole
function, and round 5's caller-side check is gone as a duplicate of it: one rule,
inside the thing it governs. Both existing callers are unchanged in behaviour —
the synchronous planting catch always owns the page, and the rejection handler
was already guarded.

Pinned red first. The new case points the launch at an executable that is not
there, then asks the port the fixture actually served on for a connection and
reads the scratch directories in the temp dir. Without the rollback the port
still accepts and the scratch tree is still there; with it, neither. The port is
recorded by wrapping the real `createBundleServer` rather than standing a double
in front of it, and the case asserts a server was created at all, or the refusal
would mean nothing.

Two oracles were discarded on the way. `rejects.toThrow()` with no argument
passes for a build that broke for its own reason, so the rejection is matched by
message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not
`TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both
arms and agreed with everything; it also still lists the handle at the moment
the close callback runs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): read the render fixture's rollback in a temp root of its own

Two defects in the case I committed in cb1833e675, both found by running it.

The anti-slop gate refuses module mocking, and it is right to: the case recorded
the served port by mocking the harness module around the real
`createBundleServer`. Gone, with no disable.

Its replacement read the shared temp directory for the fixture's scratch prefix,
which the render check next door writes to from a worker of its own. So the case
watched that tree appear and be swept up mid-run and called it a change: one red
in four alone, and red in the full suite, where the two run together. `TMPDIR`
now points at a directory this worker made, so the fixture's scratch tree lands
somewhere nothing else writes and what is left in there afterwards was left by
the setup under test. The failed launch also leaves Playwright artifacts and a
browser profile in there, which are Playwright's to clean, so the reading is
filtered to the name the fixture gives its own trees.

The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as
Node spells it, and read a tick after the close callback, because the handle is
still listed while that callback runs.

Both halves now fail on their own without the thing they measure: with no
rollback at all the socket count is one above its baseline, twice out of twice;
with the rollback but no `rm`, the scratch tree is still there. Three green runs
with both.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the started document to the mount in the turn that started it

Round 6's two LOW items, and the pins for the owner-checked release.

LOW 1. `started` was assigned in the `.then` after the build, a microtask later
than the start sequence and the resize listener it installs. A dispose in that
window found nothing started, skipped the teardown and released the page with the
document still running on it. The build now takes an `adopt` callback and calls it
as its last statement, inside the guarded region, so whoever has to undo the
start is holding it before that turn ends. Pinned by queuing the dispose behind
the document import the build awaits, which lands in exactly that window: without
the change the started document's resize listener survives the dispose, five red
runs out of five.

The owner-checked release, which landed in 8b37221b57 without a pin of its own.
The one path that reaches a mount's cleanup holding someone else's page is a
rejected import: everywhere else the build re-reads the claim after its await and
stops, but a rejection never gets that far. So the pin drives that — the chunk
fails for the first mount only, the mount is disposed while pending, a second one
is built into the same element as Reload does, and then the first rejection
arrives. Without the guard inside `release` it empties the live mount's host:
three red runs out of three, on the markup. It also disposes the abandoned handle
a second time afterwards and asserts nothing moves, which is LOW 2's missing pin
for round 5's F2.

That case is its own file because the import has to fail before the mount module
loads, and the mocking the failure needs is only permitted in `.test.ts` — the
anti-slop override does not cover `.test.mjs`, which is what refused the port
recording in the render fixture's case. It fails once, so the mount that replaces
it gets real modules and is a live document worth protecting; its own resize
listener is the witness that it started.

Two oracles were dropped. Vitest reports its own message when a mock factory
throws, not the one thrown, so which import failed is read from the factory's
counter instead. And a counter of successful factory calls read zero even though
the second mount got a working document, which measures vitest's caching rather
than this code; the live mount's listener replaced it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the listener wrappers the mount pins install

The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit
`any` parameters in each of the two mount pins, from arrow functions assigned
over `window.addEventListener` and `window.removeEventListener`. An overloaded
method gives an assigned arrow no contextual parameter types, so each wrapper's
`type`, `listener` and `options` were implicitly `any` under
`tsconfig.test.json`, which the product typecheck does not read.

Both wrappers now take their parameters from the bound original as
`Parameters<typeof realAdd>` and spread them through, so the signature is the
real one rather than three widened parameters. No casts and no `any`.

Re-verified that the change did not quietly disarm either pin, because a recorder
that counted nothing would also go green: with `release` unguarded the rejection
case still fails on the live mount's markup, and with the adopt deferred by a
microtask the single-mount case still fails on the started document's resize
listener surviving its dispose.

The ratchet itself is the finding worth keeping. It is not part of the mobile
`tsc` the rest of my gate set runs, and it had dropped out of that set when these
folds began, so three reports listed the other ratchets and not this one. It is
back in, and stays in.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart

Round 7's five items.

1. The queued-dispose case's precondition was vacuous. It read the host for a
missing container, which dispose empties on every path, so a build that returned
straight after its ownership check satisfied it. The wrapper now counts resize
adds and the case asserts exactly one, which is the document having started. Red
under that mutation, on the count.

2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that
also refused replaced the error the caller needs — the reason the setup failed.
The rollback is best-effort now and the original error is what comes back.

3. That cleanup stopped at the first throw, so a browser refusing to close took
the socket and the scratch tree with it, which is the leak the rollback exists to
prevent. Each of the three is asked independently and the first failure is
rethrown after all three have been tried.

4. The rejection case restores its `window` patch in a `finally`, as its sibling
does, so a failure part way through no longer leaves the patched functions behind
for everything that runs after it.

5. `dispose` left `started` set. `send` reads it, and what it holds names the
page's one set of document modules, so a stale handle could route a host command
into whichever document is live next. Nulled, and pinned: the stale handle pings,
and with the old code the *live* mount's `receive` answers `pong`, because the
scope's seam belongs to it by then. The precondition is the live handle's own ping
being answered, so the silence is the stale handle declining rather than the
command doing nothing.

Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup
itself, reachable only by making a browser or a socket refuse to close, and
standing something in front of Playwright to do it is what the anti-slop gate
refuses in this file's suffix. The rollback's own pin still covers the path that
matters, and both changes are read by it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-20 18:49:38 -04:00
committed by GitHub
parent 1b9d218df5
commit ec82173130
76 changed files with 5962 additions and 5165 deletions
@@ -394,10 +394,13 @@ export async function mobileWebAppRouteClosure(routeModule) {
* C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are
* not the same computation, and a divergence between them is a finding rather than noise.
*/
export async function mobileWebAppModuleClosure(entryModules) {
export async function mobileWebAppModuleClosure(entryModules, { absWorkingDir } = {}) {
const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES)
const result = await esbuild.build({
...base,
// A census that plants a module to show the walk would report it needs a tree of its own; the
// real ones never pass this and keep measuring `mobile/`.
...(absWorkingDir ? { absWorkingDir } : {}),
// Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a
// `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure
// the native switch no browser ever loads.
@@ -384,3 +384,173 @@ export async function createBundleServer({ outDir, cspHeader, transformChunk })
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return { server, origin: `http://127.0.0.1:${String(server.address().port)}` }
}
/**
* A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror`
* that belongs to someone else.
*
* Reading `null` three times would pass on a terminal that assigned `null` over a real handler,
* which is the failure this seam exists to prevent. The sentinel is identity-checked in the page
* rather than marshalled out of it — a function does not survive `evaluate` — and it returns
* false so the browser still reports the error normally.
*/
export function installPageErrorSentinel() {
globalThis.__orcaSentinelCalls = []
const sentinel = (message) => {
globalThis.__orcaSentinelCalls.push(String(message))
return false
}
globalThis.__orcaSentinel = sentinel
window.onerror = sentinel
}
/**
* Every animation frame and timer, tagged with the mount that scheduled it.
*
* Installed before the bundle loads, so the document's own scheduling goes through it. Each
* schedule remembers the `#terminal-container` that was on the page at the time; a callback that
* runs once that element has left the document is a frame or timer of the first mount firing
* into the second, which is the whole finding. The element rather than a counter the test bumps,
* because React unmounts on its own schedule and a callback that runs while the first terminal is
* still up is not a leak. Every schedule is kept, not just the ones still owed, so the test can
* say that there was something to leak before it says that nothing did.
*/
export function installSchedulerRecorder() {
globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [] }
const state = globalThis.__orcaScheduler
const wrap = (schedule, kind) =>
function (callback, ...rest) {
if (!state.watching || typeof callback !== 'function') {
return schedule(callback, ...rest)
}
// The line that called this, which is the script the work belongs to. Line 0 is the error's
// own header and line 1 is this wrapper.
const caller = ((new Error('scheduled').stack ?? '').split('\n')[2] ?? '').trim()
const container = document.getElementById('terminal-container')
// `fired` is what makes "owed" readable: a callback that has not run is still owed, whether
// it was cancelled or is merely waiting, and cancelling never sets it.
const entry = { kind, caller, owned: container !== null, fired: false }
state.scheduled.push(entry)
return schedule(
(...args) => {
entry.fired = true
if (container !== null && !container.isConnected) {
state.leaked.push(`${kind} from ${caller}`)
}
return callback(...args)
},
...rest
)
}
globalThis.requestAnimationFrame = wrap(
globalThis.requestAnimationFrame.bind(globalThis),
'frame'
)
globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer')
globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval')
}
/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */
/**
* Every window and document listener the page holds, by target, type and phase.
*
* Identity, not a tally: `addEventListener` with a listener the target already holds is a no-op in
* the DOM, and `removeEventListener` with one it does not hold is too, so counting calls would
* report leaks a browser does not have. The set is the live listeners, which is what a snapshot
* before and after a mount can be compared on.
*/
export function installListenerRecorder() {
const live = new Map()
globalThis.__orcaListeners = {
snapshot: () =>
Object.fromEntries(
[...live.entries()]
.map(([key, listeners]) => [key, listeners.size])
.filter(([, n]) => n > 0)
)
}
const keyFor = (target, type, options) => {
const where = target === globalThis ? 'window' : target === document ? 'document' : null
if (where === null) {
return null
}
const capture = typeof options === 'object' && options !== null ? !!options.capture : !!options
return `${where} ${type}${capture ? ' capture' : ''}`
}
const add = EventTarget.prototype.addEventListener
const remove = EventTarget.prototype.removeEventListener
EventTarget.prototype.addEventListener = function (type, listener, options) {
const key = keyFor(this, type, options)
if (key !== null && listener) {
if (!live.has(key)) {
live.set(key, new Set())
}
live.get(key).add(listener)
}
return add.call(this, type, listener, options)
}
EventTarget.prototype.removeEventListener = function (type, listener, options) {
const key = keyFor(this, type, options)
if (key !== null && listener) {
live.get(key)?.delete(listener)
}
return remove.call(this, type, listener, options)
}
}
export function installCspViolationRecorder() {
globalThis.__orcaCspViolations = []
document.addEventListener('securitypolicyviolation', (event) => {
globalThis.__orcaCspViolations.push(
`${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}`
)
})
}
/**
* Every computed property of `html` and `body`, as one string each.
*
* The oracle for "the page mount styles only what it owns" is a page of the same application with
* no terminal on it, so the comparison is against another page rather than against a list of
* properties someone chose. A rule that escaped the host would have to move one of these.
*/
export async function readRootComputedStyles(page) {
return await page.evaluate(() => {
const read = (element) => {
const computed = getComputedStyle(element)
const entries = []
for (const property of computed) {
entries.push(`${property}: ${computed.getPropertyValue(property)}`)
}
return entries.join('\n')
}
return { body: read(document.body), html: read(document.documentElement) }
})
}
/**
* What the terminal's injected sheet matches, and how much of it there is.
*
* The rule count is the precondition for the empty list: a sheet that was never planted, or one
* the browser refused, would match nothing for a reason that has nothing to do with scoping.
*/
export async function terminalStyleReach(page) {
return await page.evaluate(() => {
const sheet = [...document.styleSheets].find(
(one) => one.ownerNode?.id === 'orca-terminal-document-style'
)
if (!sheet) {
return { rules: 0, outside: ['the terminal stylesheet is not in the head'] }
}
const host = document.querySelector('.orca-terminal-document-host')
const outside = []
for (const rule of sheet.cssRules) {
for (const element of document.querySelectorAll(rule.selectorText)) {
if (!host || !host.contains(element)) {
outside.push(`${rule.selectorText} matched ${element.tagName}`)
}
}
}
return { rules: sheet.cssRules.length, outside }
})
}
@@ -0,0 +1,116 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
textInputFontSizeOffenders,
unresolvedTextInputStyles
} from './mobile-web-app-text-input-font-size-seam.mjs'
/**
* What putting the terminal on the page costs the session route's closure.
*
* The route is not served on the page until C7.7 — its module is still the native switch and
* there is no `.web.tsx` beside it — but the closure the bundler would walk is the same one, and
* the terminal is by far the largest thing in it. Measured here so the trade is a number rather
* than a claim, and so that a later change cannot quietly put the engine string back.
*
* Measured against `origin/main` at 9fbdfc592c, which is the merge base this branch now sits on:
*
* modules 4277 -> 4320 (+43)
* local modules 926 -> 970 (+44)
* minified bytes 3,868,833 -> 3,812,418 (-56,415)
*
* The route gets smaller. It sheds six modules — the native component, the 612 KiB engine string,
* the 105 KiB generated document script, the HTML module and the shell and close around it, all
* string literals of a program the page cannot run — and gains fifty: the document's own 39, the
* component, its mount, the stylesheet and markup, the two the controller split made, and xterm
* with its two addons behind them at 607,945 bytes minified ESM on their own.
*
* Two earlier readings of the same measurement, against the bases this branch sat on before:
* -47,255 at 51ae7b1b03 and -55,561 at 0ce0fc99a2. They differ because C7.1's own round-1 fold
* deleted `URL_TAP_WEBVIEW_JS` from a module only the page's component brings into this closure,
* so the saving lands on the after side and no base can show it.
*/
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const mobileDir = join(projectDir, 'mobile')
const SESSION_ROUTE = 'app/h/[hostId]/session/[worktreeId].tsx'
/** Gone with the WebView: string literals of a program the page has no way to run. */
const SHED = [
'src/terminal/TerminalWebView.tsx',
'src/terminal/terminal-webview-engine.generated.ts',
'src/terminal/terminal-webview-document-script.generated.ts',
'src/terminal/terminal-webview-html.ts',
'src/terminal/terminal-webview-html/document-shell.ts',
'src/terminal/terminal-webview-html/document-close.ts'
]
/** The component, its mount, the stylesheet and the markup, and the modules the splits made. */
const GAINED_OUTSIDE_THE_DOCUMENT = [
'src/terminal/TerminalWebView.web.tsx',
'src/terminal/terminal-web-document-mount.ts',
'src/terminal/terminal-webview-engine-css.generated.ts',
'src/terminal/terminal-webview-html.web.ts',
'src/terminal/terminal-webview-html/document-markup.ts',
'src/terminal/terminal-webview-html/document-style.ts',
// The page's half of the stylesheet: the document-level rules are dropped and the rest is held
// under the host, so what the page injects can only reach what the terminal owns.
'src/terminal/terminal-webview-html/document-style-scoping.ts',
'src/terminal/terminal-webview-ready-promises.ts',
'src/terminal/use-terminal-webview-controller.ts'
]
const XTERM_PACKAGES = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
/**
* The 16 px seam's verdict for this route, which C7.5 must leave exactly where C7.2 left it.
*
* Design §3 counted nine inputs under the floor here and C7.2 moved all nine onto the seam, so the
* answer is now none. Asserted rather than left unmeasured because the terminal's own modules
* joining this closure is precisely the kind of change that could add a tenth unread.
*/
const EXPECTED_OFFENDERS = 0
const bundles = mobileWebAppDependenciesPresent()
const describeClosure = bundles ? describe : describe.skip
describeClosure(
"the session route's page closure with the terminal on it",
() => {
it('gains the document, xterm and the addons, and sheds the engine string', async () => {
const { local, modules } = await mobileWebAppRouteClosure(SESSION_ROUTE)
for (const gone of SHED) {
expect(local, `${gone} is still in the closure`).not.toContain(gone)
}
for (const gained of GAINED_OUTSIDE_THE_DOCUMENT) {
expect(local, `${gained} is not in the closure`).toContain(gained)
}
for (const name of XTERM_PACKAGES) {
expect(
modules.some((module) => module.includes(`node_modules/${name}/`)),
`${name} is not in the closure`
).toBe(true)
}
// The document, whole: every module the generator emits except the bridge, which ruling 19
// keeps off the page because those `message` frames belong to the shell.
const documentModules = local.filter((module) => module.startsWith('src/terminal/document/'))
expect(documentModules.length).toBeGreaterThanOrEqual(36)
expect(documentModules).not.toContain('src/terminal/document/message-bridge.ts')
expect(documentModules).toContain('src/terminal/document/page-document-modules.ts')
}, 300_000)
it('leaves the 16px seam census exactly where C7.2 left it', async () => {
const closure = await mobileWebAppRouteClosure(SESSION_ROUTE)
// Two preconditions, because zero offenders is what a walk that read nothing also reports:
// the seam's own web module has to be in the closure, and no style may be unresolved.
expect(closure.local).toContain('src/platform/text-input-font-size.web.ts')
expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([])
expect(textInputFontSizeOffenders(mobileDir, closure)).toHaveLength(EXPECTED_OFFENDERS)
}, 300_000)
},
900_000
)
@@ -5,9 +5,14 @@
* where its consumer was, so a page does not go down over one — but nothing it was mounted for
* works either, and the closure pays for a module that cannot do its job.
*
* C7.6 gives the two editors the plain states they already degrade to (`rulings-ota-c7.md` ruling
* 8). The terminal is the third and is C7.5's, which drops the engine string and mounts xterm in
* the document; it is listed here rather than left unsaid so the list is the work remaining.
* C7.6 gave the two editors the plain states they already degrade to (`rulings-ota-c7.md` ruling
* 8) and left the terminal listed as the work remaining, which was C7.5's. C7.5 has done it: the
* page mounts xterm in the document and drops the engine string, so the list is now empty and
* this closure reaches that package from nowhere at all.
*
* An empty list is also what a scan that read nothing reports, so the control below no longer
* uses the list — it runs the same walk over three native modules that do import the package and
* over the three web siblings that replace them.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
@@ -21,13 +26,21 @@ const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.
const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx'
/** Still on the native component, and whose PR it is. */
const REMAINING = ['src/terminal/TerminalWebView.tsx']
/** Nothing: every consumer this closure had now resolves to a web sibling that needs no WebView. */
const REMAINING = []
/** The two this PR answered, whose `.web.tsx` the builder resolves instead. */
/** The three answered, whose `.web.tsx` the builder resolves instead of the native file. */
const ANSWERED = [
'src/components/MobileRichMarkdownEditor.web.tsx',
'src/components/MobileHtmlPreview.web.tsx'
'src/components/MobileHtmlPreview.web.tsx',
'src/terminal/TerminalWebView.web.tsx'
]
/** The native files behind those three, which do import the package. The scan's own control. */
const NATIVE_CONSUMERS = [
'src/components/MobileRichMarkdownEditor.tsx',
'src/components/MobileHtmlPreview.tsx',
'src/terminal/TerminalWebView.tsx'
]
const IMPORTS_WEBVIEW = /(?:from|import)\s*'[^']*react-native-webview'/
@@ -45,9 +58,15 @@ function webViewConsumers(closure) {
describeClosure(
'the session closure and react-native-webview',
() => {
it('reaches it from the terminal and from nothing else', async () => {
it('reaches it from nothing at all', async () => {
const closure = await mobileWebAppRouteClosure(SESSION)
expect(webViewConsumers(closure)).toEqual(REMAINING)
// The precondition an empty list needs: the walk read a closure, and read the very modules
// whose native halves are the ones that would have imported the package.
expect(closure.local.length).toBeGreaterThan(500)
for (const file of ANSWERED) {
expect(closure.local, file).toContain(file)
}
})
it('resolves both editors to their web siblings, not to the native files', async () => {
@@ -58,9 +77,10 @@ describeClosure(
}
})
it('finds a consumer when there is one, so the list above is a measurement', async () => {
// The control: the same walk over the module the list names, which does import it.
expect(webViewConsumers({ local: REMAINING })).toEqual(REMAINING)
it('finds a consumer when there is one, so the empty list above is a measurement', () => {
// The control, run over the native files rather than over the list: with the list empty,
// walking it would compare nothing against nothing and pass on a scan that reads no file.
expect(webViewConsumers({ local: NATIVE_CONSUMERS })).toEqual(NATIVE_CONSUMERS)
expect(webViewConsumers({ local: ANSWERED })).toEqual([])
})
},
@@ -0,0 +1,130 @@
/**
* The scratch route tree the terminal render check bundles, and the streams it drives.
*
* No route serves this screen until C7.7, so the component is reached through a route tree
* written to a temporary directory. That is a bundler entry and a page under test, not an
* assertion, and it is here so the check itself stays the list of things being measured. This
* step retires the moment the session route is registered.
*/
export const COLS = 80
export const ROWS = 24
/** Design §2 measured the host's own chunker at 48 KiB, so the sample is at least one full one. */
export const MIN_STREAM_BYTES = 48 * 1024
/** Printed at the top of the stream and again at the end, so the read-back covers both edges. */
export const FIRST_MARKER = 'ORCA-TERMINAL-RENDER-FIRST'
export const LAST_MARKER = 'ORCA-TERMINAL-RENDER-LAST'
/**
* An escape-dense sample of at least 48 KiB: an SGR colour change every cell, an erase-to-end and
* an absolute cursor position per row. Built here rather than committed because it is a function
* of the grid, and a fixture sized from the constant it is meant to exercise proves nothing.
*/
export function escapeDenseStream() {
const esc = '\u001b'
const rows = []
rows.push(`${esc}[2J${esc}[H${FIRST_MARKER}\r\n`)
let row = 2
let bytes = rows[0].length
while (bytes < MIN_STREAM_BYTES) {
const cells = []
for (let column = 0; column < COLS - 1; column++) {
const colour = 31 + ((row + column) % 7)
cells.push(`${esc}[${String(colour)};1m${String.fromCharCode(97 + ((row + column) % 26))}`)
}
const line = `${esc}[${String(row)};1H${esc}[K${cells.join('')}${esc}[0m\r\n`
rows.push(line)
bytes += line.length
row += 1
}
rows.push(`${LAST_MARKER}\r\n`)
return rows.join('')
}
/**
* The scratch route: the component under test, its handle and its notifies on `globalThis`.
*
* Written rather than committed because it is the bundler's entry and nothing else — a file under
* `mobile/app` would register a route the shell could open. `beforeinput` is recorded off the
* xterm helper textarea, which is design §8's cheap half of the IME question: it says what the
* browser reports for text entering a terminal on the page, and leaves a composing IME on a real
* keyboard to the device step it cannot answer.
*/
export function probeRouteSource(componentPath) {
return `import { useCallback, useEffect, useRef, useState } from 'react'
import { TextInput, View } from 'react-native'
import { TerminalWebView } from ${JSON.stringify(componentPath)}
export default function TerminalProbeRoute() {
const handleRef = useRef(null)
const [mounted, setMounted] = useState(true)
const onSelectionCopy = useCallback((text) => {
globalThis.__orcaTerminalCopied = text
}, [])
const onWebReady = useCallback(() => {
globalThis.__orcaTerminalReady = true
}, [])
const onEngineError = useCallback((message) => {
globalThis.__orcaTerminalEngineErrors.push(message)
}, [])
useEffect(() => {
globalThis.__orcaTerminalEngineErrors = globalThis.__orcaTerminalEngineErrors ?? []
globalThis.__orcaTerminalBeforeInput = []
globalThis.__orcaTerminalProbe = {
init: (cols, rows, data) => handleRef.current?.init(cols, rows, data, false, []),
write: (data) => handleRef.current?.write(data),
selectAll: () => handleRef.current?.doSelectAll(),
measure: () => handleRef.current?.measureFitDimensions(),
awaitReady: () => handleRef.current?.awaitReady(),
setMounted: (next) => setMounted(next)
}
const onBeforeInput = (event) => {
globalThis.__orcaTerminalBeforeInput.push({
inputType: event.inputType,
data: event.data === null ? null : String(event.data),
isComposing: !!event.isComposing
})
}
document.addEventListener('beforeinput', onBeforeInput, true)
return () => document.removeEventListener('beforeinput', onBeforeInput, true)
}, [])
return (
<View testID="terminal-probe" style={{ flex: 1 }}>
{mounted ? (
<TerminalWebView
ref={handleRef}
onWebReady={onWebReady}
onEngineError={onEngineError}
onSelectionCopy={onSelectionCopy}
/>
) : null}
{/* The shape the terminal's live input takes on the page: xterm's own textarea is inert by
the document's design, so this is where typed text arrives. */}
<TextInput testID="terminal-live-input" style={{ fontSize: 16 }} />
</View>
)
}
`
}
/**
* The same page with no terminal on it.
*
* The page entry already carries Zod, which probes for `new Function` and swallows the
* `EvalError`, so the shell's `script-src 'self'` records one refusal on any route before a line
* of terminal code runs. Comparing against this control is what makes "zero violations" a
* statement about the terminal rather than about the bundle it lives in.
*/
export const CONTROL_SOURCE = `import { View } from 'react-native'
export default function ControlRoute() {
globalThis.__orcaTerminalControlMounted = true
return <View testID="terminal-control" />
}
`
export const LAYOUT_SOURCE = `import { Slot } from 'expo-router'
export default function ProbeLayout() {
return <Slot />
}
`
@@ -0,0 +1,212 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
import {
COLS,
CONTROL_SOURCE,
LAYOUT_SOURCE,
probeRouteSource,
ROWS
} from './mobile-web-app-terminal-probe-route.mjs'
import {
createBundleServer,
installCspViolationRecorder,
installListenerRecorder,
installPageErrorSentinel,
installSchedulerRecorder,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
/**
* The scratch bundle the terminal render check runs against, and the two ways to open a page on it.
*
* Its own module because the check's cases are the thing under review and the server, the browser
* and the scratch route tree are not. Nothing here is module-scoped: the fixture holds what it
* built in the closures it hands back, so two of them could not read each other's browser.
*/
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const mobileDir = join(projectDir, 'mobile')
export const PROBE_ROUTE = '/h/terminal-probe'
export const CONTROL_ROUTE = '/h/terminal-control'
const PAGE_ROUTE_PATTERNS = [PROBE_ROUTE, CONTROL_ROUTE]
const SHELL_SESSION_ID = 'terminal-render-session'
const SHELL_BUILD_ID = 'terminal-render-build'
const SHELL_HOST = {
id: 'terminal-render-host',
name: 'Terminal Render Host',
endpoint: 'ws://terminal-render',
lastConnected: 1
}
/**
* Everything the fixture allocated, in the reverse of the order it took it.
*
* Shared by the normal close and the rollback, because a setup that fell over halfway has exactly
* the same things to give back as one that ran to the end — it just has fewer of them. The server
* close is awaited rather than fired: it holds a listening socket, and a socket still open when
* the file finishes keeps the vitest worker alive after its last test has reported.
*/
async function closeTerminalRenderFixture({ browser, scratch, server }) {
// Each one is asked independently, because stopping at the first refusal is how the socket and
// the scratch tree survived in the first place: a browser that will not close would take the
// other two down with it. The first failure is what comes back, after all three have been tried.
const failures = []
const attempt = async (close) => {
try {
await close()
} catch (error) {
failures.push(error)
}
}
await attempt(() => browser?.close())
await attempt(
() =>
server &&
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
)
await attempt(() => rm(scratch, { recursive: true, force: true }))
if (failures.length > 0) {
throw failures[0]
}
}
/**
* Builds the bundle, serves it under the shell's own policy, and launches the browser.
*
* Nothing survives a setup that throws. The browser is launched last and is the step most likely
* to fail — no Chromium on the machine, an `ORCA_MOBILE_WEB_RENDER_BROWSER` that points nowhere —
* and by then the server is listening and the scratch tree is on disk. A caller that never got a
* handle back has nothing to close, so this closes them itself and rethrows what actually went
* wrong rather than whatever the cleanup might say.
*/
export async function startTerminalRenderFixture() {
const cspHeader = await readShellCsp()
const bridgeVersion = await readBridgeProtocolVersion()
const faultGrant = await readBridgeFaultGrant()
const scratch = await mkdtemp(join(tmpdir(), 'orca-c75-terminal-render-'))
let browser = null
let served = null
try {
const appDir = join(scratch, 'app')
const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(routeDir, { recursive: true })
await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE)
// Extensionless, so the bundler resolves the `.web.tsx` sibling exactly as it would for a
// real route. Naming the `.tsx` would mount the WebView wrapper no browser can render.
await writeFile(
join(routeDir, 'terminal-probe.tsx'),
probeRouteSource(join(mobileDir, 'src', 'terminal', 'TerminalWebView'))
)
await writeFile(join(routeDir, 'terminal-control.tsx'), CONTROL_SOURCE)
const built = await buildMobileWebAppBundle({
appDir,
outDir: join(scratch, 'bundle'),
pageRoutes: [
{ pathname: PROBE_ROUTE, grants: [] },
{ pathname: CONTROL_ROUTE, grants: [] }
]
})
served = await createBundleServer({ outDir: built.outDir, cspHeader })
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {})
})
} catch (error) {
// Swallowed on purpose: what the caller needs is the reason the setup failed, and a cleanup
// that also refuses would replace it with something about a socket. The rollback is
// best-effort; the original error is the contract.
await closeTerminalRenderFixture({ browser, scratch, server: served?.server }).catch(() => {})
throw error
}
const { origin } = served
async function openPage(
pathname,
{ errorSentinel = false, listeners = false, scheduler = false, beforeNavigate } = {}
) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
await beforeNavigate?.(page)
if (scheduler) {
await page.addInitScript(installSchedulerRecorder)
}
if (listeners) {
await page.addInitScript(installListenerRecorder)
}
await page.addInitScript(installCspViolationRecorder)
if (errorSentinel) {
await page.addInitScript(installPageErrorSentinel)
}
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: { pathname, params: {} },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant],
pageRoutes: PAGE_ROUTE_PATTERNS,
replies: {}
})
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 60_000,
polling: 250
})
return { errors, page }
}
async function openTerminal(options) {
const opened = await openPage(PROBE_ROUTE, options)
await opened.page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
return opened
}
return {
openPage,
openTerminal,
close: () => closeTerminalRenderFixture({ browser, scratch, server: served.server })
}
}
/**
* The markup, then `init`, then the engine.
*
* xterm is opened by the document's `init`, not by the mount: the component plants the elements
* and the modules read them, and the terminal appears on the first host command. So the order
* here is the order a session screen uses, and each step is waited for rather than assumed —
* `.xterm` before `init` would time out on a page that was working perfectly.
*/
export async function openProbeTerminal(page) {
await page.locator('#terminal-container').waitFor({ state: 'attached', timeout: 30_000 })
await page.evaluate(
([cols, rows]) => globalThis.__orcaTerminalProbe.init(cols, rows, ''),
[COLS, ROWS]
)
// Attached rather than visible: the replacement surface is hidden until its writes drain, and
// the commit that reveals it is the last step of the same rAF chain `awaitReady` waits on.
await page.locator('#terminal-surface .xterm').waitFor({ state: 'attached', timeout: 30_000 })
await page.evaluate(() => globalThis.__orcaTerminalProbe.awaitReady())
}
@@ -0,0 +1,89 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import { startTerminalRenderFixture } from './mobile-web-app-terminal-render-fixture.mjs'
/**
* What the render fixture gives back when it never finishes starting.
*
* The handle is the only way to close it, so a setup that throws before returning one leaves the
* caller nothing to call: `afterAll` has no fixture, and the listening socket and the scratch tree
* stay where they are. The socket is the part that bites — an open server handle keeps the vitest
* worker alive after its last test has reported, so the file hangs rather than failing.
*
* The browser is the step that fails in practice and the last one taken, so by then everything
* else is allocated. It is made to fail the way it actually does, by pointing the launch at an
* executable that is not there, rather than by standing a double in front of Playwright.
*/
/** The name the fixture gives its scratch tree, which is the only thing in here it owns. */
const SCRATCH_PREFIX = 'orca-c75-terminal-render-'
const describeFixture = mobileWebAppDependenciesPresent() ? describe : describe.skip
let temporaryRoot = null
let realTemporaryRoot = null
beforeAll(async () => {
// The fixture names its scratch tree after `os.tmpdir()`, and the render check next door names
// its own the same way in a worker of its own — so reading the shared temp directory reports
// that one appearing and being swept up mid-case, which is not this case's business. Pointing
// `TMPDIR` at a directory of this worker's own makes the reading exact: whatever is left in
// here afterwards was left by the setup under test.
temporaryRoot = await mkdtemp(join(tmpdir(), 'orca-c75-fixture-rollback-'))
realTemporaryRoot = process.env.TMPDIR
process.env.TMPDIR = temporaryRoot
})
afterAll(async () => {
if (realTemporaryRoot === undefined) {
delete process.env.TMPDIR
} else {
process.env.TMPDIR = realTemporaryRoot
}
await rm(temporaryRoot, { recursive: true, force: true })
})
/**
* Listening sockets this process holds, which is the handle that keeps a worker alive.
*
* Spelled as Node spells it: filtering on `TCPSERVERWRAP` matches nothing and reads zero in both
* arms, which agrees with everything. And the handle is still listed while the close callback
* runs, so the reading is taken a tick later, once the loop has let it go.
*/
async function settledListeningSockets() {
await new Promise((resolve) => setTimeout(resolve, 50))
return process.getActiveResourcesInfo().filter((resource) => resource === 'TCPServerWrap').length
}
describeFixture('the terminal render fixture', () => {
it('takes back the server and the scratch tree when the browser will not start', async () => {
const socketsBefore = await settledListeningSockets()
const realBrowser = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
process.env.ORCA_MOBILE_WEB_RENDER_BROWSER = join(temporaryRoot, 'orca-c75-no-such-browser')
try {
// Named, not merely thrown, and this is also the precondition the two readings below need.
// A bare `toThrow` passes for a build that broke for its own reason, which would leave
// nothing serving and nothing on disk and agree with both assertions for the wrong reason.
// Reaching the launch at all means `createBundleServer` returned, because it is the
// statement before it.
await expect(startTerminalRenderFixture()).rejects.toThrow(
/Failed to launch chromium because executable doesn't exist/
)
} finally {
if (realBrowser === undefined) {
delete process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
} else {
process.env.ORCA_MOBILE_WEB_RENDER_BROWSER = realBrowser
}
}
expect(await settledListeningSockets()).toBe(socketsBefore)
// The fixture's own trees, by the name it gives them. The launch that failed leaves Playwright
// artifacts and a browser profile in here too, and those are Playwright's to clean, not the
// rollback's.
const left = (await readdir(temporaryRoot)).filter((entry) => entry.startsWith(SCRATCH_PREFIX))
expect(left).toEqual([])
}, 600_000)
})
@@ -0,0 +1,793 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
escapeDenseStream,
FIRST_MARKER,
LAST_MARKER,
MIN_STREAM_BYTES
} from './mobile-web-app-terminal-probe-route.mjs'
import {
CONTROL_ROUTE,
openProbeTerminal,
PROBE_ROUTE,
startTerminalRenderFixture
} from './mobile-web-app-terminal-render-fixture.mjs'
import { readRootComputedStyles, terminalStyleReach } from './mobile-web-app-render-harness.mjs'
/**
* The page's terminal, in a real browser, under the policy the shell sends.
*
* Everything below the contract is new on the page: xterm is an import rather than a 612 KiB
* string in a WebView document, the document's modules run in the page's own realm, and the
* stylesheet and the elements they read by id are planted by the component. None of that is
* settled by a module test. What a browser settles is whether it opens at all under
* `script-src 'self'` with no `unsafe-inline` and no `unsafe-eval`, whether a real terminal byte
* stream reaches the buffer intact, and whether anything the page does is refused by the policy.
*
* The stream is deliberately escape-dense: colour changes, cursor moves and erases at every cell
* boundary, which is the shape that expands worst through the transport and the shape a TUI
* actually paints. It is read back through the document's own selection path — select all, then
* the Copy button the overlay carries — so the oracle is the component's `onSelectionCopy` prop
* and not a private reach into xterm.
*
* No route serves this screen until C7.7, so the component is bundled through a scratch route
* tree. That step retires the moment the session route is registered.
*/
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let fixture = null
let controlCspViolations = []
const stream = escapeDenseStream()
const openPage = (pathname, options) => fixture.openPage(pathname, options)
const openTerminal = (options) => fixture.openTerminal(options)
beforeAll(async () => {
if (!bundles) {
return
}
fixture = await startTerminalRenderFixture()
}, 600_000)
afterAll(async () => {
await fixture?.close()
})
/** Violations this page recorded that the control did not, which is the terminal's own account. */
async function terminalCspViolations(page) {
const seen = await page.evaluate(() => globalThis.__orcaCspViolations)
const shared = new Set(controlCspViolations.map(stripAssetPath))
return seen.map(stripAssetPath).filter((entry) => !shared.has(entry))
}
/** The asset name is a content hash and the port is per run; neither is part of the finding. */
function stripAssetPath(entry) {
return entry.replace(/ @ .*$/, '')
}
describeRender(
'the terminal on the page',
() => {
it('records what the page refuses before any terminal is on it', async () => {
// Run first, and the two cases below subtract it, so their zero is the terminal's own
// account rather than the bundle's. A control that mounted nothing would report nothing for
// the wrong reason, so the route's own marker is the precondition.
const { page } = await openPage(CONTROL_ROUTE)
await page.waitForFunction(() => globalThis.__orcaTerminalControlMounted === true, {
timeout: 60_000,
polling: 100
})
controlCspViolations = await page.evaluate(() => globalThis.__orcaCspViolations)
console.log('[c7.5][csp-control]', JSON.stringify(controlCspViolations.map(stripAssetPath)))
// Nothing, which is a stronger fact than this case was built for. It first read
// `script-src: eval` — Zod probing for a JIT with `new Function` and swallowing the throw,
// so no page error and no console line reported it — and main's jitless banner closed that
// before this branch merged it. The subtraction stays: it is what makes the cases below say
// "the terminal added none" rather than "none were seen".
expect(controlCspViolations.map(stripAssetPath)).toEqual([])
await page.close()
}, 300_000)
it('opens xterm under the shipped policy and paints a dense stream into its buffer', async () => {
const { errors, page } = await openTerminal()
await openProbeTerminal(page)
const applied = await page.evaluate((data) => {
globalThis.__orcaTerminalProbe.write(data)
return data.length
}, stream)
expect(applied).toBeGreaterThanOrEqual(MIN_STREAM_BYTES)
// Read back through the document's own path: select all, then the overlay's Copy button,
// which posts the buffer text to the component's onSelectionCopy prop.
await page.evaluate(() => globalThis.__orcaTerminalProbe.selectAll())
await page.waitForFunction(
() => document.getElementById('selection-overlay')?.classList.contains('active') === true,
{ timeout: 30_000, polling: 100 }
)
await page.evaluate(() => document.getElementById('sel-menu-copy').click())
await page.waitForFunction(() => typeof globalThis.__orcaTerminalCopied === 'string', {
timeout: 30_000,
polling: 100
})
const copied = await page.evaluate(() => globalThis.__orcaTerminalCopied)
console.log(
'[c7.5][stream]',
JSON.stringify({ appliedBytes: applied, readBackChars: copied.length })
)
expect(copied).toContain(FIRST_MARKER)
expect(copied).toContain(LAST_MARKER)
// The escapes were consumed by the parser rather than printed as text.
expect(copied).not.toContain('\u001b')
expect(copied).not.toContain('[31;1m')
expect(await terminalCspViolations(page)).toEqual([])
expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors)).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('leaves the page its own window.onerror across mount and dispose', async () => {
// The page installs a handler before the bundle loads, so the terminal meets one that is
// not its to take. Identity is checked in the page: the same function object at all three
// points, not merely a non-null one and not merely the same shape.
const { page } = await openTerminal({ errorSentinel: true })
expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true)
await openProbeTerminal(page)
expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true)
// Both reporters see the same uncaught error: the page keeps the one it installed, and the
// terminal's own listener still works. Without the second half the readings above would
// pass on a terminal that had simply stopped reporting.
await page.evaluate(() => {
setTimeout(() => {
throw new Error('orca-terminal-render-uncaught')
}, 0)
})
const sawIt = (entries) =>
entries.some((entry) => entry.includes('orca-terminal-render-uncaught'))
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.some((entry) =>
entry.includes('orca-terminal-render-uncaught')
),
{ timeout: 30_000, polling: 100 }
)
expect(sawIt(await page.evaluate(() => globalThis.__orcaSentinelCalls))).toBe(true)
// Dispose takes the terminal's listener off and leaves the page's handler where it was.
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
expect(await page.evaluate(() => window.onerror === globalThis.__orcaSentinel)).toBe(true)
const before = await page.evaluate(() => {
setTimeout(() => {
throw new Error('orca-terminal-render-after-dispose')
}, 0)
return globalThis.__orcaTerminalEngineErrors.length
})
await page.waitForFunction(
() =>
globalThis.__orcaSentinelCalls.some((entry) =>
entry.includes('orca-terminal-render-after-dispose')
),
{ timeout: 30_000, polling: 100 }
)
// The page's handler saw it and the terminal's did not, which is what dispose has to mean.
expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors.length)).toBe(before)
await page.close()
}, 300_000)
it('installs no window.onerror on a page that had none', async () => {
// The other half: with nothing installed the terminal must not leave one behind either, so
// a later consumer still finds the slot free.
const { page } = await openTerminal()
expect(await page.evaluate(() => window.onerror)).toBe(null)
await openProbeTerminal(page)
expect(await page.evaluate(() => window.onerror)).toBe(null)
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
expect(await page.evaluate(() => window.onerror)).toBe(null)
await page.close()
}, 300_000)
/**
* A terminal that is mounted, taken down and mounted again has to be a terminal again.
*
* The document's modules are ES modules: their bodies run once per page, so anything they did
* as they were parsed — reading their elements by id, installing the error reporter, adding
* listeners — a second mount would inherit from the first, pointing at elements that are no
* longer in the document. Nothing above the contract would notice: `onWebReady` still fires,
* because readiness is the component's own handshake and not a claim about the engine.
*
* So the assertions are about the live DOM and the live paths, not about readiness.
*/
/** The listeners the page holds with no terminal on it, which is what two mounts can differ by. */
async function listenersWithNoTerminal(page) {
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
return page.evaluate(() => globalThis.__orcaListeners.snapshot())
}
async function assertLiveTerminal(page, label) {
await page.locator('#terminal-surface .xterm').waitFor({ state: 'attached', timeout: 30_000 })
expect(
await page.evaluate(() => document.querySelectorAll('#terminal-surface .xterm').length),
`${label}: xterm elements in the live DOM`
).toBeGreaterThan(0)
// The selection overlay is the document's own element, reached through the handle: it only
// activates if `handleMsg` is talking to the elements that are actually on the page.
await page.evaluate(() => globalThis.__orcaTerminalProbe.selectAll())
await page.waitForFunction(
() => document.getElementById('selection-overlay')?.classList.contains('active') === true,
{ timeout: 30_000, polling: 100 }
)
// And the reporter, which is the seam that is installed once per mount.
const marker = `orca-remount-${label}`
await page.evaluate((thrown) => {
globalThis.__orcaTerminalEngineErrors = []
setTimeout(() => {
throw new Error(thrown)
}, 0)
}, marker)
await page.waitForFunction(
(thrown) => globalThis.__orcaTerminalEngineErrors.some((entry) => entry.includes(thrown)),
marker,
{ timeout: 30_000, polling: 100 }
)
}
it('is a live terminal again after an unmount and a remount', async () => {
const { page } = await openTerminal()
await openProbeTerminal(page)
await assertLiveTerminal(page, 'first-mount')
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await assertLiveTerminal(page, 'remount')
await page.close()
}, 300_000)
it('is a live terminal again after the user reloads a failed one', async () => {
// The other way a second mount happens, and the one a user reaches: the terminal fails
// before it is ready, the engine-error overlay appears, and Reload disposes the document
// and builds another inside the same component. Driven end to end rather than by calling
// the handler — an uncaught error before the first `init` is fatal by the document's own
// rule, which is what puts the overlay on screen.
const { page } = await openTerminal()
await page.locator('#terminal-container').waitFor({ state: 'attached', timeout: 30_000 })
await page.evaluate(() => {
setTimeout(() => {
throw new Error('orca-terminal-render-fatal')
}, 0)
})
const reload = page.getByText('Reload')
await reload.waitFor({ timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
})
await reload.click()
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await assertLiveTerminal(page, 'after-reload')
await page.close()
}, 300_000)
it('names the cause when the document chunk will not load', async () => {
// The component reaches the document through a dynamic import, so the document is its own
// chunk and the chunk can fail: offline, a hashed filename that no longer exists after a
// deploy, a module that throws as it evaluates. That is a rejected promise and nothing
// else — no engine ran, so no `error` notify is ever posted. Without the rejection being
// routed it is an unhandled rejection and a blank frame until the 15 s readiness watchdog.
//
// The fault is the real one: the chunk is identified by what it carries and refused at the
// wire, rather than a stub swapped in for the mount.
// A string literal only `host-notify` carries, so the chunk is recognised by its contents
// rather than by a filename that is a content hash or by a declaration name a minifier
// renames. It has to be unique to the document: the route's own chunk carries the
// component, the controller and the notification dispatcher, and refusing that one would
// take the whole route down instead of the document.
const documentChunkMarker = 'terminal runtime error'
let aborted = null
const served = []
const { page } = await openPage(PROBE_ROUTE, {
beforeNavigate: async (opened) => {
await opened.route('**/*.js', async (route) => {
const response = await route.fetch()
const body = await response.text()
served.push(route.request().url())
if (aborted === null && body.includes(documentChunkMarker)) {
aborted = route.request().url()
await route.abort('failed')
return
}
await route.fulfill({ response, body })
})
}
})
// The chunk is fetched when the component mounts, which is after the page entry is up, so
// the refusal is waited for rather than asserted on the way past. A run where nothing
// matched would otherwise fail below for the wrong reason.
await expect
.poll(() => aborted, {
timeout: 60_000,
message: `no served script carried the document; saw ${served.join(', ')}`
})
.not.toBe(null)
await page.waitForFunction(
() =>
(globalThis.__orcaTerminalEngineErrors ?? []).some((entry) =>
entry.includes('terminal document failed to load')
),
undefined,
{ timeout: 60_000, polling: 100 }
)
// And the user-visible half: the overlay, with its Reload, rather than a blank frame.
await page.getByText('Reload').waitFor({ timeout: 30_000 })
await page.unrouteAll({ behavior: 'ignoreErrors' })
await page.close()
}, 300_000)
it('comes back from Reload while the chunk it is waiting on is still in flight', async () => {
// The other end of the case above: the chunk does not fail, it is merely slow — a cold CDN
// edge, a phone on a train. The document is reached by a dynamic import, so the mount is in
// flight while the 15 s readiness watchdog runs out and puts the overlay on the screen, and
// ruling 20 names that overlay's Reload as the way back. Reload is a second mount, so it is
// refused outright unless the first mount's cleanup could give the page back while its
// import was still unresolved — which is what the handle being synchronous is for.
//
// Held past the watchdog rather than mocked past it, because the window under test is the
// one between the claim and the import resolving, and only a real pending request has it.
const HOLD_MS = 20_000
let held = null
const { page } = await openPage(PROBE_ROUTE, {
listeners: true,
beforeNavigate: async (opened) => {
await opened.route('**/*.js', async (route) => {
const response = await route.fetch()
const body = await response.text()
if (held === null && body.includes('terminal runtime error')) {
held = route.request().url()
await new Promise((resolve) => setTimeout(resolve, HOLD_MS))
}
await route.fulfill({ response, body })
})
}
})
await expect.poll(() => held, { timeout: 60_000 }).not.toBe(null)
// The watchdog, named: the overlay has to be the one the stall raises, not an engine error
// from somewhere else, or Reload would be answering a different question.
await page.waitForFunction(
() =>
(globalThis.__orcaTerminalEngineErrors ?? []).some((entry) =>
entry.includes('no ready signal')
),
undefined,
{ timeout: 60_000, polling: 100 }
)
const reload = page.getByText('Reload')
await reload.waitFor({ timeout: 30_000 })
expect(
await page.evaluate(() => globalThis.__orcaTerminalReady === true),
'the first mount was still waiting on its chunk when Reload appeared'
).toBe(false)
await reload.click()
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await assertLiveTerminal(page, 'reload-during-import')
// And the mount the Reload abandoned has to have come to nothing. Its chunk arrives while
// the second mount is running on the same scope, so a build that resumed without re-reading
// the claim would install its listeners into this page and reset the live mount's scope,
// nulling the undo that takes the error reporter off. Read against a page that mounted once
// and disposed once: the abandoned mount is the only difference between them, so zero
// difference is the abandoned mount having touched nothing.
const afterAbandoned = await listenersWithNoTerminal(page)
const control = await openTerminal({ listeners: true })
await openProbeTerminal(control.page)
expect(afterAbandoned).toEqual(await listenersWithNoTerminal(control.page))
await control.page.close()
await page.unrouteAll({ behavior: 'ignoreErrors' })
await page.close()
}, 300_000)
it('leaves the page the listeners it found, across a mount and a dispose', async () => {
// Ruling 20 moved every install into a start function and ruling 21 gave each one a stop,
// and the document installs on `window` and `document` both: the resize refit, the error
// reporter, the tap and gesture listeners the surface modules arm. A stop that forgets one
// does not fail anything visible — the next mount simply adds a second copy, and the page
// accumulates a listener per terminal it has ever shown.
//
// The comparison is drawn across a second mount rather than against the bare page: the
// component mounts as the route does, so there is no moment before the first terminal to
// photograph. Both readings are taken with no terminal on the page, so a mount that leaks
// once leaks again and the two disagree.
const { page } = await openTerminal({ listeners: true })
await openProbeTerminal(page)
const before = await listenersWithNoTerminal(page)
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
const whileLive = await page.evaluate(() => globalThis.__orcaListeners.snapshot())
const after = await listenersWithNoTerminal(page)
// The precondition: a mount that installed nothing would satisfy the equality below for
// exactly the reason the case exists to refuse.
expect(whileLive, 'the mount installed listeners the dispose has to take back').not.toEqual(
before
)
expect(after).toEqual(before)
await page.close()
}, 300_000)
it('still reports runtime errors after a first mount spent the non-fatal budget', async () => {
// Ruling 21's finding, end to end. `reportEngineError` caps non-fatal notifies at five so a
// per-frame thrower cannot flood the host. That counter is the document's, not the mount's:
// a first terminal that spends it leaves the second one mute, reporting nothing however it
// fails, while every other signal — readiness, paint, selection — says the terminal is fine.
const { page } = await openTerminal()
await openProbeTerminal(page)
await page.evaluate(() => {
for (let index = 0; index < 6; index++) {
setTimeout(() => {
throw new Error(`orca-budget-burn-${String(index)}`)
}, 0)
}
})
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.filter((entry) =>
entry.includes('orca-budget-burn')
).length >= 5,
{ timeout: 30_000, polling: 100 }
)
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await page.evaluate(() => {
globalThis.__orcaTerminalEngineErrors = []
setTimeout(() => {
throw new Error('orca-second-mount-error')
}, 0)
})
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.some((entry) =>
entry.includes('orca-second-mount-error')
),
{ timeout: 30_000, polling: 100 }
)
await page.close()
}, 300_000)
it('cancels the timers it armed, so none of the first mount fires into the second', async () => {
// The other half of the same rule. A timer the first terminal armed has no owner after
// dispose, and on the second mount it acts on the terminal that replaced it — hiding an
// indicator nobody raised. Frames are the case below, which provokes them deliberately;
// each asserts on its own witness so neither can stand in for the other.
// The document is its own chunk, and the point is what *it* scheduled: xterm's renderer
// schedules frames of its own that a disposed terminal simply ignores, and the browser
// cannot unschedule those. So the chunk is identified on the wire, by a literal only
// `host-notify` carries, and a leak is a callback that chunk scheduled.
let documentChunk = null
const { page } = await openPage(PROBE_ROUTE, {
scheduler: true,
beforeNavigate: async (opened) => {
await opened.route('**/*.js', async (route) => {
const response = await route.fetch()
const body = await response.text()
if (body.includes('terminal runtime error')) {
documentChunk = new URL(route.request().url()).pathname
}
await route.fulfill({ response, body })
})
}
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
expect(documentChunk, 'the document was served as its own chunk').not.toBe(null)
// Enough rows for a scrollback, so the wheel below reveals the scroll indicator: that is
// the document's longest-lived piece of scheduled work, a 550 ms timer to hide it again,
// which outlives an unmount even on a loaded machine. The same wheel leaves the
// smooth-scroll frame owed. Both are asked for in the task that tells the component to go.
// One touch on the surface arms the long-press timer: 500 ms, held on the scope, cancelled
// by `stopTapDispatch`. It is the document's own timer and it needs nothing rendered, so
// the provocation cannot race the engine — the precondition below says whether it landed.
await page.evaluate(() => {
globalThis.__orcaScheduler.watching = true
const surface = document.getElementById('terminal-surface')
surface.dispatchEvent(
new TouchEvent('touchstart', {
bubbles: true,
cancelable: true,
touches: [new Touch({ identifier: 1, target: surface, clientX: 100, clientY: 400 })],
changedTouches: [
new Touch({ identifier: 1, target: surface, clientX: 100, clientY: 400 })
]
})
)
globalThis.__orcaTerminalProbe.setMounted(false)
})
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
// Long enough for the slowest timer of the first mount to have fired if it survived.
await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000)))
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
// The precondition: there was something to leak. A wheel that reached nothing would agree
// with the empty list below for the wrong reason.
expect(
scheduler.scheduled.filter(
(entry) => entry.owned && entry.kind === 'timer' && entry.caller.includes(documentChunk)
).length
).toBeGreaterThan(0)
expect(
scheduler.leaked.filter(
(entry) => entry.startsWith('timer ') && entry.includes(documentChunk)
)
).toEqual([])
await page.unrouteAll({ behavior: 'ignoreErrors' })
await page.close()
}, 300_000)
it('takes back the frames it is owed, not only the timers', async () => {
// The timer case above is witnessed by a 550 ms timeout, which every module's own stop
// cancels by the handle the scope holds. A frame is the other shape: `applyFitScale` asks
// for one through the scope's registry and never holds its id, so `stopFitScale` can only
// bump the token it tests itself against — the frame still runs. Nothing but
// `cancelDocumentFrames` takes it back.
//
// Two things have to be pinned down for that to be readable, and the first version of this
// case had neither.
//
// The witness has to be owed whenever the dispose lands. A single refit is not: the retry
// loop commits on its first attempt whenever the grid still measures, so one resize buys
// one frame and a dispose after it owes nothing — which agrees with an empty leak list for
// exactly the reason under test, once in five runs. So the refit is re-armed from a frame
// of the test's own, which leaves the document owed a frame at the end of every frame the
// browser serves, and dispose cannot land inside one.
//
// And the leak has to be counted from the moment dispose returned, not from the moment the
// host element left the DOM. React unmounts in two steps: the mutation phase detaches the
// host, and the passive cleanup that calls `dispose` runs after it — 1 ms apart here, 20 to
// 35 ms apart with the CPU throttled 20x, which is the CI runner this failed on. A frame
// served in that gap runs with a detached container while the document is still live and
// has not been asked to stop, and no registry could take it back. It went through
// `scheduleDocumentFrame` like every other; the old oracle called it a leak because it
// judged by the container rather than by dispose. Only what runs after the last statement
// of `dispose` is the document keeping something it gave up.
let documentChunk = null
const { page } = await openPage(PROBE_ROUTE, {
scheduler: true,
beforeNavigate: async (opened) => {
await opened.route('**/*.js', async (route) => {
const response = await route.fetch()
const body = await response.text()
if (body.includes('terminal runtime error')) {
documentChunk = new URL(route.request().url()).pathname
}
await route.fulfill({ response, body })
})
}
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
expect(documentChunk, 'the document was served as its own chunk').not.toBe(null)
await page.evaluate((chunk) => {
const state = globalThis.__orcaScheduler
state.disposed = null
state.watching = true
// `dispose` empties the host and drops its class last, after `cancelDocumentFrames`, so
// the class going is the moment it returned. Observed on the element rather than on the
// tree because React may have detached it already.
const host = document.querySelector('.orca-terminal-document-host')
const observer = new MutationObserver(() => {
if (state.disposed !== null || host.classList.contains('orca-terminal-document-host')) {
return
}
state.disposed = {
// A cancelled frame never runs, so it is still owed here. That is the point.
owed: state.scheduled.filter(
(entry) => entry.kind === 'frame' && !entry.fired && entry.caller.includes(chunk)
).length,
leakedBefore: state.leaked.length
}
observer.disconnect()
})
observer.observe(host, { attributes: true, attributeFilter: ['class'] })
const pulse = () => {
if (state.disposed !== null) {
return
}
globalThis.dispatchEvent(new Event('resize'))
requestAnimationFrame(pulse)
}
requestAnimationFrame(pulse)
globalThis.setTimeout(() => globalThis.__orcaTerminalProbe.setMounted(false), 200)
}, documentChunk)
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000)))
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
expect(
scheduler.disposed?.owed,
'the document owed a frame at the moment dispose returned'
).toBeGreaterThan(0)
expect(
scheduler.leaked
.slice(scheduler.disposed.leakedBefore)
.filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk))
).toEqual([])
await page.unrouteAll({ behavior: 'ignoreErrors' })
await page.close()
}, 300_000)
it('styles what it owns, and only that', async () => {
// The document's sheet says `*`, `html` and `body` because inside a WebView it owns the
// page. Appended to the head of a React Native Web application it owns nothing: those three
// selectors set the application's background, its overflow and every element's box model,
// on every screen the shell can show, and go on doing it after the terminal is gone.
//
// Ruling 19's shape: the page mount may style only what it owns. So the document-level
// rules are never injected and every remaining selector is held under the host's class.
// The oracle is a page of the same application with no terminal on it.
const control = await openPage(CONTROL_ROUTE)
const expected = await readRootComputedStyles(control.page)
await control.page.close()
const { page } = await openTerminal()
await openProbeTerminal(page)
expect(await readRootComputedStyles(page), 'roots while the terminal is mounted').toEqual(
expected
)
// And nothing in the sheet reaches past the host, which is the rule the comparison above
// cannot see: a selector that matched something outside would not have to change `body`.
const mounted = await terminalStyleReach(page)
// The precondition: there are rules to escape with.
expect(mounted.rules).toBeGreaterThan(0)
expect(mounted.outside).toEqual([])
// The positive half, which the two above cannot give: a sheet that reached nothing at all
// would satisfy both of them. These are four things the terminal looks like only because
// the rules arrive — one from xterm's sheet, three from the document's own — read off the
// live elements rather than off the stylesheet text.
expect(
await page.evaluate(() => {
const host = document.querySelector('.orca-terminal-document-host')
const xterm = host.querySelector('.xterm')
const viewport = host.querySelector('.xterm-viewport')
const overlay = host.querySelector('#selection-overlay')
return {
// xterm's own sheet: the grid is positioned against this, and its rows are absolute.
xtermPosition: getComputedStyle(xterm).position,
// The document's: the terminal scrolls itself, so the viewport shows no scrollbar
// and reserves no width for one.
viewportOverflowY: getComputedStyle(viewport).overflowY,
viewportReservesScrollbar: viewport.offsetWidth !== viewport.clientWidth,
// The document's: the overlay sits in unscaled viewport coordinates above the grid.
overlayPosition: getComputedStyle(overlay).position
}
})
).toEqual({
xtermPosition: 'relative',
viewportOverflowY: 'hidden',
viewportReservesScrollbar: false,
overlayPosition: 'fixed'
})
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
expect(await readRootComputedStyles(page), 'roots after dispose').toEqual(expected)
// The sheet stays in the head for the next mount, and matches nothing until there is one.
const disposed = await terminalStyleReach(page)
expect(disposed.rules).toBe(mounted.rules)
expect(disposed.outside).toEqual([])
await page.close()
}, 300_000)
it('measures a fit through the handle and records what beforeinput reports', async () => {
const { page } = await openTerminal()
await openProbeTerminal(page)
// The handle's own round trip: a measure is a command in and a notify back, and on the page
// both halves are direct calls rather than a bridge. Null would mean the document answered
// nothing, or answered a grid too small to fit.
const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.measure())
expect(fit).not.toBeNull()
expect(fit.cols).toBeGreaterThanOrEqual(20)
expect(fit.rows).toBeGreaterThanOrEqual(8)
// xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
// read-only, untabbable and `inputmode=none` so touch and hardware keys go to the screen's
// input instead. Asserted rather than assumed, because it is why the probe below types
// somewhere else.
const textarea = await page.evaluate(() => {
const element = document.querySelector('#terminal-surface .xterm-helper-textarea')
return element === null
? null
: {
readOnly: element.readOnly,
tabIndex: element.tabIndex,
inputMode: element.getAttribute('inputmode')
}
})
expect(textarea).toEqual({ readOnly: true, tabIndex: -1, inputMode: 'none' })
// Design §8's cheap half of the IME question: what a browser reports for text entering a
// terminal on the page, which arrives at the screen's own input. A composing IME on a real
// soft keyboard is the device step, which this does not claim to answer.
await page.getByTestId('terminal-live-input').focus()
await page.keyboard.type('ab')
await page.waitForFunction(() => globalThis.__orcaTerminalBeforeInput.length >= 2, {
timeout: 30_000,
polling: 100
})
const beforeInput = await page.evaluate(() => globalThis.__orcaTerminalBeforeInput)
console.log('[c7.5][beforeinput]', JSON.stringify(beforeInput.slice(0, 4)))
expect(beforeInput.map((entry) => entry.inputType)).toContain('insertText')
expect(beforeInput.map((entry) => entry.data)).toContain('a')
expect(beforeInput.every((entry) => entry.isComposing === false)).toBe(true)
expect(await terminalCspViolations(page)).toEqual([])
await page.close()
}, 300_000)
},
900_000
)
@@ -0,0 +1,114 @@
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppModuleClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
/**
* The 612 KiB xterm engine string, and where the page must never meet it.
*
* `terminal-webview-engine.generated.ts` is one minified IIFE of xterm plus two addons, built to
* be injected into the WebView's HTML document as text. On the page the same engine arrives as an
* import, so the string is 610 KiB of dead weight — the largest single module in the session
* route's closure, and unusable there besides, because the shell's CSP has `script-src 'self'`
* and no nested frame to load a document into.
*
* Nothing stops it entering: the module the document's `<style>` needs exported the engine string
* beside it, so one import of the CSS would have pulled the whole thing in. The CSS is now its own
* generated artifact and this is the fence. The document's modules are the entry set, because they
* are what the page imports; the plant below is what says the walk would notice.
*/
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const mobileDir = join(projectDir, 'mobile')
const documentDir = join(mobileDir, 'src', 'terminal', 'document')
const ENGINE_MODULE = 'src/terminal/terminal-webview-engine.generated.ts'
const ENGINE_CSS_MODULE = 'src/terminal/terminal-webview-engine-css.generated.ts'
const bundles = mobileWebAppDependenciesPresent()
const describeClosure = bundles ? describe : describe.skip
/**
* Every module the document is made of, as entry points.
*
* The document is one script whose modules run in a pinned order and mostly reach each other by
* side effect, so no single one of them is the root of a graph that holds the rest. Naming all of
* them is what makes "the engine string is in none of their closures" a claim about the document
* rather than about whichever module happened to be picked.
*/
async function documentEntryPoints() {
const names = (await readdir(documentDir))
.filter((name) => name.endsWith('.ts'))
.filter((name) => !name.includes('.test'))
.sort()
expect(names.length).toBeGreaterThan(30)
return names.map((name) => `src/terminal/document/${name.replace(/\.ts$/, '')}`)
}
describeClosure(
'the terminal engine string against the page',
() => {
it('is in no closure of the document modules the page imports', async () => {
const { local } = await mobileWebAppModuleClosure(await documentEntryPoints())
expect(local).not.toContain(ENGINE_MODULE)
// The precondition: a walk that resolved nothing would also contain nothing.
expect(local).toContain('src/terminal/document/document-scope.ts')
expect(local).toContain('src/terminal/document/terminal-init.ts')
}, 180_000)
it('is in no closure of the page terminal component either', async () => {
const { local } = await mobileWebAppModuleClosure(['src/terminal/TerminalWebView'])
expect(local).not.toContain(ENGINE_MODULE)
// The extensionless specifier is what the bundle ships, so this is the page's component and
// its `.web.ts` half of the HTML — naming the `.tsx` would measure the WebView no browser
// loads. Both are asserted, because the assertion above holds vacuously for the native pair.
expect(local).toContain('src/terminal/TerminalWebView.web.tsx')
expect(local).toContain('src/terminal/terminal-webview-html.web.ts')
expect(local).toContain(ENGINE_CSS_MODULE)
expect(local).not.toContain('src/terminal/terminal-webview-html.ts')
expect(local).not.toContain('src/terminal/document/message-bridge.ts')
}, 180_000)
it('is still what the native document reads its CSS beside', async () => {
// The shell rather than `terminal-webview-html`, which now has a `.web.ts` sibling the walk
// would resolve instead and so measure the page's half — the opposite of the claim. The
// shell is the module that reads both generated ones, so the cases above cannot pass by the
// CSS having quietly gone missing.
const { local } = await mobileWebAppModuleClosure([
'src/terminal/terminal-webview-html/document-shell'
])
expect(local).toContain(ENGINE_MODULE)
expect(local).toContain(ENGINE_CSS_MODULE)
}, 180_000)
it('would be reported if a document module imported it', async () => {
// Planted in a scratch tree rather than under src/terminal/document, so nothing else in the
// repository ever walks the plant and no other census has to know it exists.
const scratch = await mkdtemp(join(tmpdir(), 'orca-c75-engine-closure-'))
try {
const planted = join(scratch, 'src', 'terminal', 'document')
await mkdir(planted, { recursive: true })
await writeFile(
join(planted, 'planted.ts'),
"import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'\n" +
'export const planted = XTERM_ENGINE_JS.length\n'
)
await mkdir(join(scratch, 'src', 'terminal'), { recursive: true })
await writeFile(
join(scratch, 'src', 'terminal', 'terminal-webview-engine.generated.ts'),
"export const XTERM_ENGINE_JS = 'planted'\n"
)
const { local } = await mobileWebAppModuleClosure(['./src/terminal/document/planted'], {
absWorkingDir: scratch
})
expect(local).toContain(ENGINE_MODULE)
} finally {
await rm(scratch, { recursive: true, force: true })
}
}, 180_000)
},
600_000
)
+1
View File
@@ -1,5 +1,6 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
src/terminal/terminal-webview-engine-css.generated.ts
src/terminal/terminal-webview-document-script.generated.ts
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
.expo/
@@ -24,6 +24,12 @@ import { importTypeScriptModule } from './import-typescript-module.mjs'
const mobileRoot = path.resolve(import.meta.dirname, '..')
const entry = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-html.ts')
const enginePath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
const engineCssPath = path.join(
mobileRoot,
'src',
'terminal',
'terminal-webview-engine-css.generated.ts'
)
export const TERMINAL_DOCUMENT_FIXTURE_PATH = path.join(
mobileRoot,
@@ -62,9 +68,10 @@ export function terminalDocumentFixture(document, engineJs, engineCss) {
}
async function main() {
const [{ XTERM_HTML }, { XTERM_ENGINE_JS, XTERM_ENGINE_CSS }] = await Promise.all([
const [{ XTERM_HTML }, { XTERM_ENGINE_JS }, { XTERM_ENGINE_CSS }] = await Promise.all([
importTypeScriptModule(entry),
importTypeScriptModule(enginePath)
importTypeScriptModule(enginePath),
importTypeScriptModule(engineCssPath)
])
const fixture = terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS)
await writeFile(TERMINAL_DOCUMENT_FIXTURE_PATH, fixture)
@@ -3,8 +3,12 @@ import path from 'node:path'
import * as esbuild from 'esbuild'
import { importTypeScriptModule } from './import-typescript-module.mjs'
import {
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
TERMINAL_DOCUMENT_RESET_CALL,
TERMINAL_DOCUMENT_SCOPE_MODULE,
terminalDocumentStartFunctionName,
terminalDocumentStopFunctionName
} from './terminal-document-module-order.mjs'
/**
@@ -158,19 +162,60 @@ export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
'terminal-webview-document-script.generated.ts'
)
/**
* The start functions the emitted document calls, in module order (ruling 20).
*
* Presence is read from the source rather than listed here: a module that has no top-level effect
* exports no start function, and one that grows an effect is reached the moment it does. The
* declaration is matched on its own line because that is how esbuild's TypeScript prints it and
* how every module in this directory writes it.
*/
export async function terminalDocumentStartCalls(moduleNames) {
return await declaredFunctions(moduleNames, terminalDocumentStartFunctionName)
}
/** The stop functions, in module order. The page runs them in reverse; the WebView never stops. */
export async function terminalDocumentStopCalls(moduleNames) {
return await declaredFunctions(moduleNames, terminalDocumentStopFunctionName)
}
async function declaredFunctions(moduleNames, nameFor) {
const found = []
for (const name of moduleNames) {
const source = await readFile(path.join(documentDirectory, `${name}.ts`), 'utf8')
const declared = nameFor(name)
if (new RegExp(`^export function ${declared}\\(\\) \\{$`, 'm').test(source)) {
found.push(declared)
}
}
return found
}
/**
* The document's whole script: every module in the order the document had, inside the one function
* scope it has always been.
* scope it has always been, and then the one call sequence that starts them.
*/
export async function buildTerminalDocumentScript() {
const emitted = []
// The scope object goes first: every module below reads it, and the document is one function
// scope, so it has to exist before any of them run. It is the only part of the emitted script
// the hand-written document did not have.
for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) {
// the hand-written document did not have, and the host seams come ahead of it because its
// defaults are those six functions.
const order = [
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER
]
for (const name of order) {
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
}
return `(function() {\n${emitted.join('\n')}\n})();`
// Rulings 20 and 21: the modules above only declare. The scope's reset comes first, so the
// state every module reads is the state a fresh parse has; then every element read, listener
// and reporter install runs, once here and per mount on the page, in the order both hosts share.
const calls = [TERMINAL_DOCUMENT_RESET_CALL, ...(await terminalDocumentStartCalls(order))].map(
(name) => `${INDENT}${name}();`
)
return `(function() {\n${emitted.join('\n')}\n${calls.join('\n')}\n})();`
}
async function main() {
@@ -7,6 +7,19 @@ const require = createRequire(import.meta.url)
const scriptDir = import.meta.dirname
const mobileRoot = path.resolve(scriptDir, '..')
const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
/**
* The stylesheet is written beside the engine rather than inside it because the two have different
* consumers. The WebView document needs both, as text; the web page needs the CSS and must never
* resolve the 612 KiB engine string, which is unusable under the shell's `script-src 'self'` and
* is the largest module the session route's closure would otherwise carry. One file each is what
* lets the page import one without reaching the other.
*/
const cssOutputPath = path.join(
mobileRoot,
'src',
'terminal',
'terminal-webview-engine-css.generated.ts'
)
const target = 'chrome74'
const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
@@ -93,16 +106,29 @@ async function main() {
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
const source = [
const header = [
'// Generated by scripts/build-terminal-webview-engine.mjs.',
`// Packages: ${versions.join(', ')}.`,
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`,
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
''
].join('\n')
await writeFile(outputPath, source)
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`
]
await Promise.all([
writeFile(
outputPath,
[
...header,
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
''
].join('\n')
),
writeFile(
cssOutputPath,
[
...header,
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
''
].join('\n')
)
])
}
await main()
@@ -5,12 +5,17 @@
*
* Both the generator and the equivalence test read this, so neither can drift from the other.
*/
/**
* The host seams, emitted ahead of the scope object: the scope's defaults *are* these functions,
* and the factory that reads them runs as the script is parsed.
*/
export const TERMINAL_DOCUMENT_HOST_SEAMS_MODULE = 'document-host-seams'
/** The scope object, emitted ahead of everything else because everything else reads it. */
export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope'
export const TERMINAL_DOCUMENT_MODULE_ORDER = [
'runtime-constants',
'terminal-handle',
'query-reply',
'surface-swap',
'text-scaling',
@@ -46,3 +51,34 @@ export const TERMINAL_DOCUMENT_MODULE_ORDER = [
'surface-touch-gestures',
'message-bridge'
]
/**
* The per-module start function's name, by convention rather than by a second list.
*
* Ruling 20: no module does work as it is parsed, so each one that had a top-level effect now
* exports one function holding it. The generator calls the ones that exist, in module order, at
* the foot of the document; the page calls the same names per mount. A convention rather than a
* list because a list is a second place to forget.
*/
export function terminalDocumentStartFunctionName(moduleName) {
return (
'start' +
moduleName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
)
}
/** The per-module stop function's name, by the same convention (ruling 21). */
export function terminalDocumentStopFunctionName(moduleName) {
return terminalDocumentStartFunctionName(moduleName).replace(/^start/, 'stop')
}
/**
* The scope's reset, called ahead of every start (ruling 21).
*
* Module top level holds no mutable state, so a second mount's state comes from here and nowhere
* else. The WebView runs it once at parse, where it restores what the factory just built.
*/
export const TERMINAL_DOCUMENT_RESET_CALL = 'resetTerminalDocumentScope'
+75 -376
View File
@@ -1,396 +1,95 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react'
import { Platform, View } from 'react-native'
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import {
TerminalWebViewEngineErrorOverlay,
useTerminalWebViewEngineErrorState
} from './terminal-webview-engine-error-state'
import { TerminalWebViewEngineErrorOverlay } from './terminal-webview-engine-error-state'
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
import { createTerminalWriteCoalescer } from './terminal-write-coalescer'
import { useTerminalWebViewController } from './use-terminal-webview-controller'
type Props = TerminalWebViewProps
export type { TerminalWebViewHandle } from './terminal-webview-contract'
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
{
style,
terminalTheme,
textScale = 1,
onWebReady,
onEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
},
ref
) {
const webViewRef = useRef<WebView>(null)
const isWebReadyRef = useRef(false)
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
const messageIdRef = useRef(0)
const pendingPingIdRef = useRef<number | null>(null)
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
const measureResolveRef = useRef<
((result: { cols: number; rows: number } | null) => void) | null
>(null)
// Why: each init() call posts 'init' to the WebView and arms a fresh
// ready promise. WebView's init() rAF chain ends with a 'ready' notify
// that resolves it. measureFitDimensions awaits this so it doesn't
// race ahead of term.open() / renderService population.
const readyPromiseRef = useRef<Promise<void> | null>(null)
const readyResolveRef = useRef<(() => void) | null>(null)
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
useTerminalWebViewEngineErrorState(onEngineError)
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
isWebReadyRef,
reportEngineError
)
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(
function TerminalWebView(props, ref) {
const webViewRef = useRef<WebView>(null)
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
const id = messageIdRef.current
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id }))
return id
}, [])
const post = useCallback((command: TerminalWebViewCommand & { id: number }) => {
webViewRef.current?.postMessage(JSON.stringify(command))
}, [])
const flushPendingMessages = useCallback(() => {
pendingMessages.flush(sendToWebView)
}, [pendingMessages, sendToWebView])
const postMessage = useCallback(
(msg: TerminalWebViewCommand) => {
if (!isWebReadyRef.current) {
pendingMessages.queue(msg)
return
}
sendToWebView(msg)
},
[pendingMessages, sendToWebView]
)
// Why: a busy PTY delivers ~200 stream frames/s; coalescing here collapses the
// per-frame bridge + WebKit IPC + paint cost that runs the phone hot (#9302).
const writeCoalescer = useMemo(
() => createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data })),
[postMessage]
)
useEffect(() => {
return () => {
writeCoalescer.clear()
}
}, [writeCoalescer])
const confirmWebReady = useCallback(
(notifyParent: boolean) => {
pendingPingIdRef.current = null
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
if (notifyParent) {
onWebReady?.()
}
// Why: reload clears queued commands, so readiness must always restore the
// native-selected theme even when its value did not change in React.
sendToWebView({ type: 'set-theme', terminalTheme })
flushPendingMessages()
},
[
const {
clearEngineError,
clearWebReadyWatchdog,
flushPendingMessages,
onWebReady,
sendToWebView,
terminalTheme
]
)
engineError,
handle,
receive,
reportNativeEngineError,
resetReadiness
} = useTerminalWebViewController(props, {
post,
// iOS can preserve the native view while discarding its JS/backing-store state.
pingsOnForegroundRecovery: () => Platform.OS === 'ios'
})
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let msg: Record<string, unknown>
try {
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
} catch {
return
}
routeTerminalQueryReply(msg, onTerminalQueryReply)
useImperativeHandle(ref, () => handle, [handle])
if (msg.type === 'web-ready') {
confirmWebReady(true)
} else if (
msg.type === 'pong' &&
typeof msg.pingId === 'number' &&
msg.pingId === pendingPingIdRef.current
) {
confirmWebReady(false)
} else if (msg.type === 'ready') {
// Why: the WebView's init() rAF chain has run — term is open,
// renderService is populated, first paint has happened. Resolve
// any pending awaitReady() so a queued measure can now safely
// read cell dims.
const resolve = readyResolveRef.current
readyResolveRef.current = null
readyPromiseRef.current = null
resolve?.()
} else if (msg.type === 'measure-result') {
const resolve = measureResolveRef.current
measureResolveRef.current = null
if (resolve) {
const cols = typeof msg.cols === 'number' ? msg.cols : null
const rows = typeof msg.rows === 'number' ? msg.rows : null
resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null)
}
} else {
dispatchTerminalWebViewNotification(msg, {
reportEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
})
}
},
[
confirmWebReady,
reportEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
]
)
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
pendingPingIdRef.current = null
armWebReadyWatchdog()
// Why: messages queued for a previous WebView generation are stale after a reload;
// dropping them avoids replaying terminal chunks before the next init snapshot.
pendingMessages.clear()
writeCoalescer.clear()
}, [armWebReadyWatchdog, pendingMessages, writeCoalescer])
const handleReload = useCallback(() => {
clearEngineError()
webViewRef.current?.reload()
}, [clearEngineError])
const handleContentProcessDidTerminate = useCallback(() => {
// Why: WKWebView content-process loss is recoverable; stale commands belong
// to the dead document and the replacement must prove readiness before replay.
isWebReadyRef.current = false
pendingPingIdRef.current = null
pendingMessages.clear()
writeCoalescer.clear()
clearEngineError()
armWebReadyWatchdog()
webViewRef.current?.reload()
}, [armWebReadyWatchdog, clearEngineError, pendingMessages, writeCoalescer])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
}, [postMessage, terminalThemeKey, terminalTheme])
// Why: live-apply text-size changes to an already-mounted terminal (the pane
// stays alive while the user visits Settings), so no terminal reload is needed.
useEffect(() => {
postMessage({ type: 'set-font-scale', fontScale: textScale })
}, [postMessage, textScale])
useImperativeHandle(
ref,
() => ({
prepareForForegroundRecovery() {
if (Platform.OS !== 'ios') {
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let msg: Record<string, unknown>
try {
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
} catch {
return
}
// Why: direct ping is the only command allowed through while readiness is
// invalid; init/write commands queue until this exact document answers.
isWebReadyRef.current = false
armWebReadyWatchdog()
pendingPingIdRef.current = sendToWebView({ type: 'ping' })
receive(msg)
},
write(data: string) {
writeCoalescer.write(data)
},
init(
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
// Resolve any prior in-flight ready first so awaiters from the
// previous generation don't sit on the 3s setTimeout fallback —
// each leaked timer + closure pinned an awaiting measure caller
// for the full 3s under rapid re-init (orientation change,
// multiple resubscribes), delaying cold-start fit chains.
const priorResolve = readyResolveRef.current
if (priorResolve) {
readyResolveRef.current = null
readyPromiseRef.current = null
priorResolve()
}
readyPromiseRef.current = new Promise<void>((resolve) => {
readyResolveRef.current = resolve
})
// Why: pending chunks are pre-snapshot data; the init snapshot supersedes
// them, and writing them after init would corrupt the fresh buffer.
writeCoalescer.clear()
postMessage({
type: 'init',
cols,
rows,
initialData,
oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
})
},
resize(cols: number, rows: number) {
// Why: resize/reflow must observe all prior writes or bytes reorder.
writeCoalescer.flushNow()
postMessage({ type: 'resize', cols, rows })
},
reflow(cols: number, rows: number) {
writeCoalescer.flushNow()
postMessage({ type: 'reflow', cols, rows })
},
clear() {
writeCoalescer.clear()
postMessage({ type: 'clear' })
},
measureFitDimensions(
containerHeight?: number
): Promise<{ cols: number; rows: number } | null> {
if (!isWebReadyRef.current) {
return Promise.resolve(null)
}
return new Promise((resolve) => {
measureResolveRef.current?.(null)
let timeout: ReturnType<typeof setTimeout> | null = null
const finish = (result: { cols: number; rows: number } | null) => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (measureResolveRef.current === finish) {
measureResolveRef.current = null
}
resolve(result)
[receive]
)
const handleReload = useCallback(() => {
clearEngineError()
webViewRef.current?.reload()
}, [clearEngineError])
const handleContentProcessDidTerminate = useCallback(() => {
// Why: WKWebView content-process loss is recoverable; stale commands belong
// to the dead document and the replacement must prove readiness before replay.
resetReadiness()
clearEngineError()
webViewRef.current?.reload()
}, [clearEngineError, resetReadiness])
return (
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, props.style]}>
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={resetReadiness}
onMessage={handleMessage}
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
onRenderProcessGone={(event) =>
reportNativeEngineError('Terminal WebView render process ended', event)
}
measureResolveRef.current = finish
sendToWebView({ type: 'measure', containerHeight })
// Why: if the WebView doesn't respond within 2s (e.g., xterm
// failed to load), resolve null so the caller can disable
// Fit to Phone rather than hanging indefinitely.
timeout = setTimeout(() => {
if (measureResolveRef.current === finish) {
finish(null)
}
}, 2000)
})
},
resetZoom() {
postMessage({ type: 'reset-zoom' })
},
cancelSelect() {
postMessage({ type: 'cancel-select' })
},
doSelectAll() {
postMessage({ type: 'do-select-all' })
},
async awaitReady(): Promise<void> {
// Why: returns the in-flight ready promise (set by init); resolves
// immediately if no init is pending. Capped at 3s so a stuck
// WebView doesn't hang the caller.
const p = readyPromiseRef.current
if (!p) {
return
}
await new Promise<void>((resolve) => {
let settled = false
const timeout = setTimeout(() => {
settled = true
resolve()
}, 3000)
void p.finally(() => {
if (!settled) {
clearTimeout(timeout)
settled = true
resolve()
}
})
})
}
}),
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale, writeCoalescer]
)
return (
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
onRenderProcessGone={(event) =>
reportNativeEngineError('Terminal WebView render process ended', event)
}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
/>
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
) : null}
</View>
)
})
onContentProcessDidTerminate={handleContentProcessDidTerminate}
/>
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
) : null}
</View>
)
}
)
+148
View File
@@ -0,0 +1,148 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { View } from 'react-native'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import { TerminalWebViewEngineErrorOverlay } from './terminal-webview-engine-error-state'
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { mountTerminalWebDocument, type TerminalWebDocument } from './terminal-web-document-mount'
import { useTerminalWebViewController } from './use-terminal-webview-controller'
type Props = TerminalWebViewProps
export type { TerminalWebViewHandle } from './terminal-webview-contract'
/**
* The same terminal, with the WebView taken out.
*
* `react-native-webview` has no web build that renders anything: on the page it paints the line
* "React Native WebView does not support this platform" where the terminal was. So the page mounts
* the document itself — xterm as an import, the document's own modules as modules — and keeps the
* contract above it exactly as it was. `TerminalPaneView` and the subscription foundation hold
* `TerminalWebViewProps` and `TerminalWebViewHandle` and cannot tell which of the two they have.
*
* Both halves of the transport are the same objects the native component uses: the commands are
* `TerminalWebViewCommand`, handed to the document's own `handleMsg` instead of across a bridge,
* and every notify goes back through the controller's `receive`, which is the same dispatch.
*/
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(
function TerminalWebView(props, ref) {
const hostRef = useRef<View>(null)
const documentRef = useRef<TerminalWebDocument | null>(null)
// The document is mounted in an effect and commands can be handled before it answers, so the
// controller's queue is not enough on its own: a `set-theme` posted on the first render would
// otherwise be dropped rather than queued. Held here and replayed when the mount resolves.
const beforeMountRef = useRef<(TerminalWebViewCommand & { id: number })[]>([])
const receiveRef = useRef<((message: Record<string, unknown>) => void) | null>(null)
const post = useCallback((command: TerminalWebViewCommand & { id: number }) => {
const mounted = documentRef.current
if (mounted) {
mounted.send(command)
return
}
beforeMountRef.current.push(command)
}, [])
const controller = useTerminalWebViewController(props, {
post,
// No second content process to lose: the document is this page's own modules, and if they
// were gone so was the component holding this handle.
pingsOnForegroundRecovery: () => false
})
const { clearEngineError, confirmWebReady, engineError, handle, receive, resetReadiness } =
controller
// The page's answer to the WebView's reload: drop the document and build another one. The host
// element is keyed on it so React replaces the div rather than handing back one xterm left in.
const [generation, setGeneration] = useState(0)
useImperativeHandle(ref, () => handle, [handle])
// In an effect, not during render: React may replay or discard render work, and the document
// reads this ref from a callback that outlives the render that mounted it. The mount effect
// below is declared after this one, so the first read already sees a sink.
useEffect(() => {
receiveRef.current = receive
}, [receive])
useEffect(() => {
let cancelled = false
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: react-native-web renders View as a div and forwards the ref to it; this module only ever runs in that build.
const host = hostRef.current as unknown as HTMLElement | null
if (!host) {
return
}
// The handle comes back before the document does, which is what makes the cleanup below
// able to answer for a mount whose import is still in flight. Without it a slow chunk left
// the page claimed by a mount that had already been torn down, and Reload — the way out the
// overlay offers — was refused as a second document.
const reportMountFailure = (error: unknown) => {
// The document is reached by a dynamic import, so its chunk can fail to load — offline, a
// stale hashed filename after a deploy, an evaluation error in a module body. No engine
// ever ran, so no `error` notify is coming. It goes down the document's own reporting
// path, which names the cause in the overlay instead of leaving the readiness watchdog to
// say "no ready after 15s".
receiveRef.current?.({
type: 'error',
fatal: true,
message: `terminal document failed to load - ${
error instanceof Error ? error.message : String(error)
}`
})
}
let mounted
try {
mounted = mountTerminalWebDocument(host, (message) => receiveRef.current?.(message))
} catch (error) {
// The mount refuses synchronously when the page is already taken, and the refusal is the
// overlay's to show rather than the tree's to crash on.
reportMountFailure(error)
return
}
void mounted.ready.then(
() => {
if (cancelled) {
return
}
documentRef.current = mounted
for (const command of beforeMountRef.current) {
mounted.send(command)
}
beforeMountRef.current = []
// The WebView's document posts this as its last parsed statement, once it has seen the
// engine. Here the engine is an import that already resolved, so the mount is the moment.
confirmWebReady(true)
},
(error: unknown) => {
if (cancelled) {
return
}
reportMountFailure(error)
}
)
const live = mounted
return () => {
cancelled = true
documentRef.current = null
live.dispose()
}
// Mounted once per generation: re-running this would throw away a live terminal and its
// scrollback, and the controller's identity changes with every callback prop.
// `confirmWebReady` is read on the mount path only, which is why it is not a dependency.
}, [generation])
const handleReload = useCallback(() => {
clearEngineError()
resetReadiness()
beforeMountRef.current = []
setGeneration((previous) => previous + 1)
}, [clearEngineError, resetReadiness])
return (
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, props.style]}>
<View key={generation} ref={hostRef} style={TERMINAL_WEBVIEW_FRAME_STYLES.webview} />
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
) : null}
</View>
)
}
)
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
cancelDocumentFrames,
resetTerminalDocumentScope,
scheduleDocumentFrame,
scope
} from './document-scope'
/**
* The frames the document is owed, and the two things `cancelDocumentFrames` has to do.
*
* Taking back the pending ones is the obvious half. The other half is refusing new ones: tearing
* the terminal down runs the engine's own disposal, which calls back into these modules, and a
* frame asked for on the way out would be owed by nobody because the cancel has already run. A
* generation guard cannot help there — it makes a stale frame do nothing, but the frame still
* runs, and on the page the mount it belonged to may be gone and the next one already up.
*/
describe('the document frame registry', () => {
afterEach(() => {
resetTerminalDocumentScope()
vi.restoreAllMocks()
})
it('holds a frame until it runs, then forgets it', () => {
const frames: FrameRequestCallback[] = []
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
frames.push(callback)
return frames.length
})
resetTerminalDocumentScope()
const id = scheduleDocumentFrame(() => {})
expect(scope.scheduledFrames).toEqual([id])
frames[0]!(0)
expect(scope.scheduledFrames).toEqual([])
})
it('takes back every pending frame and then refuses to schedule', () => {
const cancelled: number[] = []
let next = 0
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => {
next += 1
return next
})
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => cancelled.push(id))
resetTerminalDocumentScope()
const first = scheduleDocumentFrame(() => {})
const second = scheduleDocumentFrame(() => {})
cancelDocumentFrames()
expect(cancelled).toEqual([first, second])
expect(scope.scheduledFrames).toEqual([])
const requests = vi.mocked(globalThis.requestAnimationFrame).mock.calls.length
expect(scheduleDocumentFrame(() => {})).toBe(-1)
expect(vi.mocked(globalThis.requestAnimationFrame).mock.calls.length).toBe(requests)
expect(scope.scheduledFrames).toEqual([])
})
it('schedules again once the scope is reset, which is what the next mount does', () => {
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 7)
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {})
resetTerminalDocumentScope()
cancelDocumentFrames()
expect(scope.framesStopped).toBe(true)
resetTerminalDocumentScope()
expect(scope.framesStopped).toBe(false)
expect(scheduleDocumentFrame(() => {})).toBe(7)
expect(scope.scheduledFrames).toEqual([7])
})
})
@@ -0,0 +1,86 @@
import type {
TerminalDocumentTerminal,
TerminalDocumentWebglAddon
} from './document-terminal-shape'
/**
* The six seams between the document and whatever is hosting it, as the document's own
* defaults. The document reads them at seven places: `postToHost` twice, `createTerminal`,
* `createUnicode11Addon`, `createWebglAddon`, `installErrorReporter` and
* `paintDocumentBackground` once each.
*
* Inside the WebView the host is React Native and the engine is an IIFE that hangs its
* constructors off `window`; on the page the host is the component that mounted these modules and
* the engine is an import. Each function below is the window read or write the document already
* did, kept at call time rather than captured when the script is parsed, and the scope carries it
* as a field the page assigns over.
*/
/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */
export type TerminalEngineError = string | null | undefined | { message?: unknown }
/** The document's runtime error reporter, taking the window error handler's own arguments. */
export type TerminalDocumentErrorReporter = (
message: string | (Event & { message?: unknown }),
source?: string,
line?: number,
column?: number,
error?: TerminalEngineError
) => void
declare global {
interface Window {
ReactNativeWebView?: { postMessage: (message: string) => void }
Unicode11Addon?: { Unicode11Addon: new () => TerminalDocumentWebglAddon }
WebglAddon?: { WebglAddon?: new () => TerminalDocumentWebglAddon }
}
const Terminal: new (options: Record<string, unknown>) => TerminalDocumentTerminal
}
export function postToReactNativeWebView(message: Record<string, unknown>) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(message))
}
}
export function createEngineTerminal(options: Record<string, unknown>) {
return new Terminal(options)
}
export function createEngineUnicode11Addon() {
return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon
? new window.Unicode11Addon.Unicode11Addon()
: null
}
export function createEngineWebglAddon() {
return window.WebglAddon && window.WebglAddon.WebglAddon
? new window.WebglAddon.WebglAddon()
: null
}
/**
* The WebView's own background: the document owns `html` and `body` there, and the terminal's
* theme is the page's colour. A page mounting these modules owns neither, so this is a field —
* painting the application's roots would recolour every screen the shell can show, and leave them
* recoloured after the terminal is gone.
*/
export function paintWindowDocumentBackground(background: string) {
document.documentElement.style.background = background
document.body.style.background = background
}
/**
* The WebView's own installation: the document owns that page, so taking `window.onerror` is
* taking nothing from anyone. A page mounting these modules must not, which is why this is a
* field rather than a statement.
*
* It hands back its own undo, because ruling 20 makes the install a per-mount act and the page's
* override is a listener that has to come off again.
*/
export function installWindowErrorReporter(report: TerminalDocumentErrorReporter) {
window.onerror = report
return function () {
window.onerror = null
}
}
@@ -1,6 +1,12 @@
import { readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
buildTerminalDocumentScript,
emitTerminalDocumentModule
} from '../../../scripts/build-terminal-document-script.mjs'
import {
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
} from '../../../scripts/terminal-document-module-order.mjs'
@@ -12,11 +18,21 @@ import {
* there is dead code that reads as live, and a name left in the list after its file goes makes the
* generator throw at build time rather than at review time. Both directions are asserted.
*
* `document-constants` is the one file that is deliberately not emitted: its exports are
* substituted into the modules that import them as literals, so the document carries its values
* without carrying the module.
* Three files are deliberately not emitted into the document, each for its own reason, and they
* are named rather than filtered by a pattern so a fourth cannot join them by looking similar.
*/
const NOT_EMITTED = 'document-constants'
const NOT_EMITTED = [
// Its exports are substituted into the modules that import them as literals, so the document
// carries its values without carrying the module.
'document-constants',
// Types only. esbuild emits nothing for it, and an empty emission would add a blank line to the
// document rather than a program.
'document-terminal-shape',
// The page's entry, not the WebView's: it imports the modules below in the order the generator
// emits them, because on the page nothing splices them into one scope.
// `page-document-module-order.test.ts` holds its list against this one.
'page-document-modules'
]
function documentModuleNames(): string[] {
return readdirSync(new URL('.', import.meta.url))
@@ -29,7 +45,8 @@ function documentModuleNames(): string[] {
describe('the document module order', () => {
it('names every module the directory holds, and only those', () => {
const expected = [
NOT_EMITTED,
...NOT_EMITTED,
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER
].sort()
@@ -37,7 +54,45 @@ describe('the document module order', () => {
})
it('names each module once, so the generator cannot emit one twice', () => {
const listed = [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]
const listed = [
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER
]
expect(listed).toHaveLength(new Set(listed).size)
})
it('emits nothing for the types-only module, which is why it is an exception', async () => {
// The reason `document-terminal-shape` is not in the order list, measured rather than
// asserted in prose: esbuild erases a module of type declarations to the empty string, and
// emitting it would put a blank line in the document instead of a program. If it ever
// declared a value this goes red, and the module belongs in the order list with its own line
// in the golden diff.
const emitted = await emitTerminalDocumentModule(
fileURLToPath(new URL('./document-terminal-shape.ts', import.meta.url))
)
expect(emitted).toBe('')
})
it('emits the host seams ahead of the scope, whose defaults are those six functions', async () => {
// Order in the emitted document, not membership in a list: `createTerminalDocumentScope()`
// runs as the script is parsed and reads the six by name, so a seams module emitted after it
// would throw on the document's first line. Non-membership cannot see that — it is satisfied
// by any arrangement — so the two texts are located in the document the generator produces.
expect(TERMINAL_DOCUMENT_MODULE_ORDER).not.toContain(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE)
expect(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE).not.toBe(TERMINAL_DOCUMENT_SCOPE_MODULE)
const script = await buildTerminalDocumentScript()
const emittedAt = async (name: string) => {
const text = await emitTerminalDocumentModule(
fileURLToPath(new URL(`./${name}.ts`, import.meta.url))
)
const at = script.indexOf(text)
expect(at, `${name} is not in the emitted document`).toBeGreaterThanOrEqual(0)
return at
}
expect(await emittedAt(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE)).toBeLessThan(
await emittedAt(TERMINAL_DOCUMENT_SCOPE_MODULE)
)
})
})
@@ -0,0 +1,259 @@
import { readFileSync } from 'node:fs'
import { parseSync } from 'oxc-parser'
import { describe, expect, it } from 'vitest'
import {
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
} from '../../../scripts/terminal-document-module-order.mjs'
/**
* Rulings 20 and 21: no module in the document does work as it is parsed, and none owns state.
*
* ES module bodies run once per page. Inside the WebView that was invisible — the script is
* parsed once per document and the document is the page — but the web component mounts these same
* modules, and a second mount re-imports nothing. An element read, a listener, or a reporter
* install left in a module body would therefore keep the *first* mount's elements forever: that is
* the defect round 1 measured, with zero `.xterm` nodes in the live DOM after a remount.
*
* So the rule is structural rather than behavioural, and it is checked structurally. Every
* emitted module may declare; none may run. What used to run lives in that module's start
* function, which both hosts call — the generated script once at the foot of the document, the
* page once per mount.
*
* Ruling 21 is the same argument about state rather than about effects. A module-level `let`
* survives a mount just as a module body does, so the second terminal inherited a spent error
* budget, the first terminal's committed surface and its momentum loop. Every mutable binding
* therefore lives on the scope, which the start sequence resets first, and module top level holds
* constants, functions and types only.
*/
const EMITTED = [
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER
]
/**
* The one module that does build something as it is parsed: the scope object every other module
* reads. It has to exist before any of them, and on the page it is one object for the life of the
* tab — which is safe precisely because the rule below holds for everything else. Each start
* function writes every field it owns, so a remount resets the scope rather than inheriting it.
* Its parse-time work touches no element, which is asserted rather than asserted-in-prose.
*/
const BUILDS_THE_SCOPE = TERMINAL_DOCUMENT_SCOPE_MODULE
/** Statement kinds that only declare. Anything else at the top level is work. */
const DECLARATION_KINDS = new Set([
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportDefaultDeclaration',
'ExportAllDeclaration',
'FunctionDeclaration',
'ClassDeclaration',
'VariableDeclaration',
'TSTypeAliasDeclaration',
'TSInterfaceDeclaration',
'TSEnumDeclaration',
'TSModuleDeclaration',
'TSDeclareFunction',
'TSImportEqualsDeclaration',
'EmptyStatement'
])
function moduleSource(name: string): string {
return readFileSync(new URL(`./${name}.ts`, import.meta.url), 'utf8')
}
/** A node's own properties, or nothing when it is not one. Read rather than asserted. */
function fieldsOf(node: unknown): [string, unknown][] {
return node !== null && typeof node === 'object' && !Array.isArray(node)
? Object.entries(node)
: []
}
function stringField(node: unknown, key: string): string {
const found = fieldsOf(node).find(([name]) => name === key)?.[1]
return typeof found === 'string' ? found : ''
}
function field(node: unknown, key: string): unknown {
return fieldsOf(node).find(([name]) => name === key)?.[1]
}
const RUNS_NOW = new Set([
'CallExpression',
'NewExpression',
'AwaitExpression',
'TaggedTemplateExpression'
])
/** What an initialiser *is* rather than what it does: its body runs later, not now. */
const RUNS_LATER = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression'])
function isElementGlobal(node: unknown): boolean {
const name = stringField(node, 'name')
return stringField(node, 'type') === 'Identifier' && (name === 'document' || name === 'window')
}
function initialiserRuns(node: unknown): boolean {
if (Array.isArray(node)) {
return node.some(initialiserRuns)
}
const type = stringField(node, 'type')
if (RUNS_NOW.has(type)) {
return true
}
if (RUNS_LATER.has(type)) {
return false
}
if (type === 'MemberExpression' && isElementGlobal(field(node, 'object'))) {
return true
}
return fieldsOf(node).some(([key, value]) => key !== 'type' && initialiserRuns(value))
}
/** Whether anything at a module's top level reaches an element, at any depth. */
function readsTheDocument(node: unknown): boolean {
if (Array.isArray(node)) {
return node.some(readsTheDocument)
}
if (isElementGlobal(node)) {
return true
}
return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value))
}
/** Every top-level `let` or `var`: state the module owns, which a second mount would inherit. */
function mutableBindingsIn(name: string, source: string): string[] {
const { program } = parseSync(`${name}.ts`, source, { lang: 'ts' })
const found: string[] = []
for (const statement of program.body) {
const declaration =
statement.type === 'ExportNamedDeclaration' ? (statement.declaration ?? statement) : statement
if (declaration.type !== 'VariableDeclaration' || declaration.kind === 'const') {
continue
}
found.push(`${name}: ${source.slice(declaration.start, declaration.end).split('\n')[0]}`)
}
return found
}
function mutableBindings(name: string): string[] {
return mutableBindingsIn(name, moduleSource(name))
}
/**
* The top-level statements that are not declarations, and the initialisers that run something.
*
* A declaration counts as work when its initialiser calls, constructs, awaits, or reaches into
* `document` or `window`: `const scrollIndicator = document.getElementById(...)` is a declaration
* by shape and a parse-time element read by effect, and it is the exact form that survived a
* remount still holding the first mount's node. Object and regex literals are not work, which is
* why this reads the tree rather than the text.
*/
function parseTimeEffects(name: string): string[] {
return parseTimeEffectsIn(name, moduleSource(name))
}
function parseTimeEffectsIn(name: string, source: string): string[] {
const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' })
expect(errors).toEqual([])
const effects: string[] = []
for (const statement of program.body) {
if (!DECLARATION_KINDS.has(statement.type)) {
effects.push(`${name}: ${statement.type}`)
continue
}
const declaration =
statement.type === 'ExportNamedDeclaration' ? (statement.declaration ?? statement) : statement
if (declaration.type !== 'VariableDeclaration') {
continue
}
for (const declarator of declaration.declarations) {
if (declarator.init && initialiserRuns(declarator.init)) {
effects.push(`${name}: ${source.slice(declarator.start, declarator.end)}`)
}
}
}
return effects
}
describe('the document modules at parse time', () => {
it('do no work: every effect is in a start function the hosts call', () => {
const modules = EMITTED.filter((name) => name !== BUILDS_THE_SCOPE)
expect(modules).toContain('runtime-constants')
expect(modules.flatMap(parseTimeEffects)).toEqual([])
})
it('build the scope, and only the scope, before the rest of them', () => {
// The exception, measured. It is one module, it is the one the order list already names as
// the scope, and nothing it does at parse time reaches an element — so a remount inherits an
// object of fields, never a stale node.
expect(parseTimeEffects(BUILDS_THE_SCOPE).length).toBeGreaterThan(0)
const source = moduleSource(BUILDS_THE_SCOPE)
const { program } = parseSync(`${BUILDS_THE_SCOPE}.ts`, source, { lang: 'ts' })
const topLevel = program.body.filter(
(statement) =>
statement.type === 'VariableDeclaration' ||
(statement.type === 'ExportNamedDeclaration' &&
statement.declaration?.type === 'VariableDeclaration')
)
expect(topLevel.some(readsTheDocument)).toBe(false)
})
it('own no mutable state: no top-level let or var outside the scope', () => {
expect(EMITTED.filter((name) => name !== BUILDS_THE_SCOPE).flatMap(mutableBindings)).toEqual([])
})
it('would report a planted one, so the empty list above is a measurement', () => {
// The precondition for the case above, run against the same reader: a module body with a
// `let` in it is the exact shape the rule refuses, and the reader has to say so.
const planted = `import { scope } from './document-scope'\nlet spent = 0\nexport function n() {\n spent++\n return scope.term\n}\n`
expect(mutableBindingsIn('planted', planted)).toEqual(['planted: let spent = 0'])
})
it('would report a planted element read, which the statement filter cannot see', () => {
// The second reader has its own precondition. A `const` initialised from the document is a
// declaration by shape and a parse-time element read by effect — the exact form that survived
// a remount holding the first mount's node — and the statement-kind filter waves it through.
const planted =
"import { scope } from './document-scope'\n" +
"const indicator = document.getElementById('scroll-indicator')\n" +
'export function n() {\n return indicator ?? scope.term\n}\n'
expect(parseTimeEffectsIn('planted', planted)).toEqual([
"planted: indicator = document.getElementById('scroll-indicator')"
])
// And the other direction, because a reader that flagged every initialiser would agree with
// the empty list above only by refusing everything: a plain literal is not work.
const inert =
"import { scope } from './document-scope'\n" +
'const options = { capture: true, passive: false }\n' +
'export function n() {\n return options.capture && scope.term !== null\n}\n'
expect(parseTimeEffectsIn('inert', inert)).toEqual([])
})
it('would report one, so the empty list above is a measurement', () => {
// The precondition. A walk that matched nothing would agree with an empty expectation just as
// happily, so the same reader is aimed at a module that does have a top-level effect: this
// test file itself, whose `describe` call is exactly the shape the rule refuses.
const source = readFileSync(
new URL('./document-parse-time-effects.test.ts', import.meta.url),
'utf8'
)
const { program } = parseSync('probe.ts', source, { lang: 'ts' })
const running = program.body.filter((statement) => !DECLARATION_KINDS.has(statement.type))
expect(running.length).toBeGreaterThan(0)
})
it('still start and stop: the functions holding what was moved out are exported', () => {
// The other half. Moving an effect out is only correct if something calls it, and the caller
// is pinned by `page-document-module-order.test.ts` against the generator's own sequence;
// this holds the shape of the names so that sequence can be derived rather than listed.
const declaring = (keyword: string) =>
EMITTED.filter((name) =>
new RegExp(`^export function ${keyword}[A-Za-z]+\\(\\) \\{$`, 'm').test(moduleSource(name))
)
expect(declaring('start').length).toBe(10)
// Ruling 21: a module that schedules a frame, a timer or a retry owes an undo for it.
expect(declaring('stop').length).toBe(9)
})
})
+185 -113
View File
@@ -1,5 +1,28 @@
import { terminalDefaultTheme, terminalTextScalePresets } from './document-constants'
import {
createEngineTerminal,
createEngineUnicode11Addon,
createEngineWebglAddon,
installWindowErrorReporter,
paintWindowDocumentBackground,
postToReactNativeWebView,
type TerminalDocumentErrorReporter
} from './document-host-seams'
import type {
TerminalDocumentDisposable,
TerminalDocumentTerminal,
TerminalDocumentTheme,
TerminalDocumentWebglAddon,
TerminalInitialOscLink
} from './document-terminal-shape'
import type { TerminalMouseGesture } from './mouse-click-drag'
import type { TerminalTouchState } from './surface-touch-gestures'
import type { TerminalTouchDispatch } from './tap-dispatch'
import type { TerminalDocumentThemeMessage } from './terminal-theme'
// Re-exported so every module that reads the scope keeps naming one import for both: the split is
// about this file's length, not about a second place to look for the engine's shape.
export type * from './document-terminal-shape'
/**
* The state the in-WebView terminal document shares across its parts.
*
@@ -23,106 +46,8 @@ import type { TerminalDocumentThemeMessage } from './terminal-theme'
* The table grows one group at a time as C7.1 extracts them; a field arrives with its group.
*/
/** One cell of a buffer line, as the document inspects it. */
/** xterm's OSC 8 link service, reached through internals and always guarded. */
export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: string } | undefined }
/** The xterm internals the OSC 8 lookup walks. */
export type TerminalDocumentCore = {
_renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } }
_oscLinkService?: TerminalOscLinkService
_inputHandler?: { _oscLinkService?: TerminalOscLinkService }
}
/** An OSC 8 link the host captured from scrollback before xterm replayed it. */
export type TerminalInitialOscLink = {
uri?: string
row: number
startCol: number
endCol: number
text?: string
}
export type TerminalDocumentCell = {
isBgDefault: () => boolean
extended?: { urlId?: number }
isInverse: () => boolean
isUnderline?: () => boolean
isStrikethrough?: () => boolean
isOverline?: () => boolean
}
/** One buffer line, as the document inspects it. */
export type TerminalDocumentLine = {
readonly length: number
translateToString: (trimRight: boolean, startColumn?: number, endColumn?: number) => string
getCell?: (x: number, cell?: TerminalDocumentCell | null) => TerminalDocumentCell | null
}
/** One side of xterm's buffer, as the document reads it. */
export type TerminalDocumentBuffer = {
readonly length: number
readonly viewportY: number
readonly baseY: number
readonly cursorY: number
readonly type: string
getNullCell?: () => TerminalDocumentCell
getLine: (index: number) => TerminalDocumentLine | undefined
}
/** As much of xterm's terminal as the document's own code touches. */
/** A terminal colour theme: xterm reads it as a flat map of slot to CSS colour. */
export type TerminalDocumentTheme = Record<string, string>
/** The xterm options the document writes; each field is owned by the group that sets it. */
export type TerminalDocumentTerminalOptions = {
theme: TerminalDocumentTheme
minimumContrastRatio: number
fontSize: number
}
export type TerminalDocumentTerminal = {
readonly cols: number
readonly rows: number
readonly buffer: { readonly active: TerminalDocumentBuffer }
options: TerminalDocumentTerminalOptions
write: (data: string, callback?: () => void) => void
open: (element: HTMLElement) => void
scrollToLine: (line: number) => void
clear: () => void
reset: () => void
selectAll: () => void
getSelection?: () => string
select: (col: number, row: number, length: number) => void
clearSelection: () => void
readonly unicode: { activeVersion: string }
attachCustomKeyEventHandler: (handler: () => boolean) => void
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
readonly textarea?: {
readOnly: boolean
tabIndex: number
setAttribute: (name: string, value: string) => void
}
readonly element?: HTMLElement
readonly _core?: TerminalDocumentCore
readonly modes?: {
bracketedPasteMode?: boolean
mouseTrackingMode?: string
applicationCursorKeysMode?: boolean
}
onLineFeed?: (listener: () => void) => TerminalDocumentDisposable
onScroll?: (listener: () => void) => TerminalDocumentDisposable
onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable
resize: (cols: number, rows: number) => void
refresh: (start: number, end: number) => void
dispose: () => void
loadAddon: (addon: TerminalDocumentWebglAddon) => void
scrollToBottom: () => void
scrollLines: (amount: number) => void
}
export type TerminalDocumentScope = {
/** `terminal-handle`: the live xterm terminal, or null before the first init. */
export type TerminalDocumentState = {
/** `terminal-init`: the live xterm terminal, or null before the first init. */
term: TerminalDocumentTerminal | null
/** `viewport-transform`: the surface's pan offset, in viewport pixels. */
panX: number
@@ -267,9 +192,59 @@ export type TerminalDocumentScope = {
surface: HTMLElement | null
/** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */
pendingTerm: TerminalDocumentTerminal | null
/** `surface-swap`: the terminal the committed surface is showing. */
committedTerm: TerminalDocumentTerminal | null
/** `surface-swap`: the surface the committed terminal is mounted on. */
committedSurface: HTMLElement | null
/** `surface-swap`: the hidden replacement surface, until it commits. */
pendingSurface: HTMLElement | null
/** `text-scaling`: the scroll indicator's track and its thumb. */
scrollIndicator: HTMLElement | null
scrollThumb: HTMLElement | null
/** `query-reply`: whether the host asked for terminal data replies. */
terminalDataRepliesEnabled: boolean
/** `selection-state-and-eviction`: rows written since the terminal opened. */
linesEverWritten: number
/** `host-notify`: non-fatal reports already sent, against the flood cap. */
nonFatalErrorNotifies: number
/** `host-notify`: undoes the host's reporter install, or null before one. */
uninstallErrorReporter: (() => void) | null
/** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */
fitRetryToken: number
/** `mouse-click-drag`: the mouse gesture in progress, or null. */
mouseGesture: TerminalMouseGesture | null
/** `tap-dispatch`: what the document-level dispatcher has latched onto. */
touchDispatch: TerminalTouchDispatch
/** `surface-touch-gestures`: the surface touch, its velocity and its momentum frame. */
touchGesture: TerminalTouchState
/** Every animation frame the document has asked for and not yet run. */
scheduledFrames: number[]
/** Whether the document has been stopped, and so asks for no more frames. */
framesStopped: boolean
}
/** An xterm listener handle, as the document disposes of one. */
/**
* The six host seams, kept out of the state above because they are the one thing a reset must
* not touch: the page sets them once per mount, before the start sequence runs.
*/
export type TerminalDocumentHostSeams = {
/** `host-notify`, `viewport-transform`: where a message for the host goes. */
postToHost: (message: Record<string, unknown>) => void
/** `terminal-init`: builds the xterm terminal. */
createTerminal: (options: Record<string, unknown>) => TerminalDocumentTerminal
/** `terminal-init`: builds the unicode11 addon, or answers null when the host has none. */
createUnicode11Addon: () => TerminalDocumentWebglAddon | null
/** `webgl-recovery`: builds the WebGL addon, or answers null when the host has none. */
createWebglAddon: () => TerminalDocumentWebglAddon | null
/** `host-notify`: installs the document's runtime error reporter with the host. */
installErrorReporter: (report: TerminalDocumentErrorReporter) => () => void
/** `terminal-theme`: paints the terminal's background behind the grid. */
paintDocumentBackground: (background: string) => void
}
/** The document's whole scope: its state, and the seams to whatever is hosting it. */
export type TerminalDocumentScope = TerminalDocumentState & TerminalDocumentHostSeams
/** The live selection; only the dragged handle is read outside the overlay slice. */
export type TerminalDocumentSelection = {
anchor: { row: number; col: number }
@@ -295,15 +270,6 @@ export type TerminalDocumentModes = {
/** One entry of the write queue: a chunk, a boundary callback, or a consumed slot. */
export type TerminalWriteQueueEntry = string | (() => void) | undefined
export type TerminalDocumentDisposable = { dispose?: () => void }
/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */
export type TerminalDocumentWebglAddon = {
onContextLoss?: (listener: () => void) => void
clearTextureAtlas?: () => void
dispose: () => void
}
/**
* The initial values, which are the ones the document's own declarations carried.
*
@@ -315,7 +281,7 @@ const statusDot = String.fromCharCode(0x23fa)
const textPresentationSelector = String.fromCharCode(0xfe0e)
const emojiPresentationSelector = String.fromCharCode(0xfe0f)
export function createTerminalDocumentScope(): TerminalDocumentScope {
function createTerminalDocumentState(): TerminalDocumentState {
return {
term: null,
panX: 0,
@@ -352,7 +318,7 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
handledMessageIds: [],
currentTextScale: 1,
terminalFontFamily: '',
firstDataPending: true,
firstDataPending: false,
activeAltScreenSnapshot: false,
currentScale: 1,
userScale: 1,
@@ -398,9 +364,115 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
tapCandidate: null,
wheelAccumDeltaY: 0,
surface: null,
pendingTerm: null
pendingTerm: null,
committedTerm: null,
committedSurface: null,
pendingSurface: null,
scrollIndicator: null,
scrollThumb: null,
terminalDataRepliesEnabled: false,
linesEverWritten: 0,
nonFatalErrorNotifies: 0,
uninstallErrorReporter: null,
fitRetryToken: 0,
mouseGesture: null,
touchDispatch: {
mode: 'idle',
touchId: null,
touchIds: null,
longPressFingerInsideOverlay: false
},
scheduledFrames: [],
framesStopped: false,
touchGesture: {
lastX: 0,
lastY: 0,
lastTime: 0,
velY: 0,
accumDelta: 0,
momentumId: null,
isPinching: false,
pinchDist: 0,
pinchScale: 0,
pinchSurfX: 0,
pinchSurfY: 0
}
}
}
/** The seams' defaults: the window reads and writes the document already did. */
function createTerminalDocumentHostSeams(): TerminalDocumentHostSeams {
return {
postToHost: postToReactNativeWebView,
createTerminal: createEngineTerminal,
createUnicode11Addon: createEngineUnicode11Addon,
createWebglAddon: createEngineWebglAddon,
installErrorReporter: installWindowErrorReporter,
paintDocumentBackground: paintWindowDocumentBackground
}
}
export function createTerminalDocumentScope(): TerminalDocumentScope {
return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() }
}
/**
* The scope back at the state a freshly parsed document has (ruling 21).
*
* The page mounts these modules more than once and an ES module body runs once per page, so this
* is what makes a second mount a second document: the start sequence calls it first, and on the
* WebView it runs once at parse, where it changes nothing. The seams are left alone — the page
* sets them before the sequence runs, and they belong to the host rather than to the terminal.
*
* Two counters carry forward instead of resetting, because they are what a stale callback is
* tested against: a frame scheduled by the mount that just went away compares its captured number
* with the one here, and a reset to zero would make the old number match again.
*/
export function resetTerminalDocumentScope() {
const generations = {
terminalGeneration: scope.terminalGeneration + 1,
fitRetryToken: scope.fitRetryToken + 1
}
Object.assign(scope, createTerminalDocumentState(), generations)
}
/** The document's own scope. The generator emits this declaration at the top of the script. */
export const scope: TerminalDocumentScope = createTerminalDocumentScope()
/**
* An animation frame the document can take back (ruling 21).
*
* A generation guard makes a stale frame *do* nothing; it still runs, and inside a WebView that
* is the same thing. On the page it is not: the mount that scheduled the frame may be gone and
* the next one already up, and a callback that reads the scope reads the new mount's. Every frame
* the document asks for is registered here so `cancelDocumentFrames` can take the pending ones
* back, which is what the page's dispose does. The id is dropped as the frame runs, so the list
* holds only what is still owed.
*/
export function scheduleDocumentFrame(callback: FrameRequestCallback) {
// A stopped document asks for nothing. Tearing the terminal down runs the engine's own
// disposal, which calls back into these modules, and a frame asked for on the way out would be
// owed by nobody — the cancel has already run. `-1` is not a live frame id, so a caller that
// holds one and cancels it later is cancelling nothing.
if (scope.framesStopped) {
return -1
}
const id = requestAnimationFrame(function (time) {
const at = scope.scheduledFrames.indexOf(id)
if (at !== -1) {
scope.scheduledFrames.splice(at, 1)
}
callback(time)
})
scope.scheduledFrames.push(id)
return id
}
/** Takes back every frame the document is still owed, and stops it asking for more. */
export function cancelDocumentFrames() {
scope.framesStopped = true
for (const id of scope.scheduledFrames) {
cancelAnimationFrame(id)
}
scope.scheduledFrames = []
}
@@ -0,0 +1,116 @@
/**
* The shape of xterm, as the document uses it.
*
* Only the members the document's own code reaches: the terminal, its buffer, the internals the
* OSC 8 lookup and the cell-geometry walk go through, and its two addons. This describes the
* engine rather than the document, which is why it is not in the scope's own file: the WebView's
* engine is a bundle on `window` and the page's is an import, and both answer exactly this.
*/
/** One cell of a buffer line, as the document inspects it. */
/** xterm's OSC 8 link service, reached through internals and always guarded. */
export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: string } | undefined }
/** The xterm internals the OSC 8 lookup walks. */
export type TerminalDocumentCore = {
_renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } }
_oscLinkService?: TerminalOscLinkService
_inputHandler?: { _oscLinkService?: TerminalOscLinkService }
}
/** An OSC 8 link the host captured from scrollback before xterm replayed it. */
export type TerminalInitialOscLink = {
uri?: string
row: number
startCol: number
endCol: number
text?: string
}
export type TerminalDocumentCell = {
isBgDefault: () => boolean
extended?: { urlId?: number }
isInverse: () => boolean
isUnderline?: () => boolean
isStrikethrough?: () => boolean
isOverline?: () => boolean
}
/** One buffer line, as the document inspects it. */
export type TerminalDocumentLine = {
readonly length: number
translateToString: (trimRight: boolean, startColumn?: number, endColumn?: number) => string
getCell?: (x: number, cell?: TerminalDocumentCell | null) => TerminalDocumentCell | null
}
/** One side of xterm's buffer, as the document reads it. */
export type TerminalDocumentBuffer = {
readonly length: number
readonly viewportY: number
readonly baseY: number
readonly cursorY: number
readonly type: string
getNullCell?: () => TerminalDocumentCell
getLine: (index: number) => TerminalDocumentLine | undefined
}
/** As much of xterm's terminal as the document's own code touches. */
/** A terminal colour theme: xterm reads it as a flat map of slot to CSS colour. */
export type TerminalDocumentTheme = Record<string, string>
/** The xterm options the document writes; each field is owned by the group that sets it. */
export type TerminalDocumentTerminalOptions = {
theme: TerminalDocumentTheme
minimumContrastRatio: number
fontSize: number
}
export type TerminalDocumentTerminal = {
readonly cols: number
readonly rows: number
readonly buffer: { readonly active: TerminalDocumentBuffer }
options: TerminalDocumentTerminalOptions
write: (data: string, callback?: () => void) => void
open: (element: HTMLElement) => void
scrollToLine: (line: number) => void
clear: () => void
reset: () => void
selectAll: () => void
getSelection?: () => string
select: (col: number, row: number, length: number) => void
clearSelection: () => void
readonly unicode: { activeVersion: string }
attachCustomKeyEventHandler: (handler: () => boolean) => void
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
readonly textarea?: {
readOnly: boolean
tabIndex: number
setAttribute: (name: string, value: string) => void
}
readonly element?: HTMLElement
readonly _core?: TerminalDocumentCore
readonly modes?: {
bracketedPasteMode?: boolean
mouseTrackingMode?: string
applicationCursorKeysMode?: boolean
}
onLineFeed?: (listener: () => void) => TerminalDocumentDisposable
onScroll?: (listener: () => void) => TerminalDocumentDisposable
onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable
resize: (cols: number, rows: number) => void
refresh: (start: number, end: number) => void
dispose: () => void
loadAddon: (addon: TerminalDocumentWebglAddon) => void
scrollToBottom: () => void
scrollLines: (amount: number) => void
}
/** An xterm listener handle, as the document disposes of one. */
export type TerminalDocumentDisposable = { dispose?: () => void }
/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */
export type TerminalDocumentWebglAddon = {
onContextLoss?: (listener: () => void) => void
clearTextureAtlas?: () => void
dispose: () => void
}
+13 -6
View File
@@ -6,7 +6,7 @@ import {
getTotalScale,
updateTransform
} from './viewport-transform'
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
export function getCellHeight() {
if (!scope.term || !scope.term._core) {
@@ -61,16 +61,15 @@ export function adjustRowsForViewport() {}
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
const FIT_RETRY_MAX_FRAMES = 60
let fitRetryToken = 0
export function applyFitScale(reason: string) {
if (!scope.term || !scope.term.element) {
return
}
const token = ++fitRetryToken
const token = ++scope.fitRetryToken
let attempts = 0
let lastScrollWidth = -1
function attempt() {
if (token !== fitRetryToken) {
if (token !== scope.fitRetryToken) {
return
}
if (!scope.term || !scope.term.element) {
@@ -99,9 +98,9 @@ export function applyFitScale(reason: string) {
commitFitScale(reason, attempts, 'timeout')
return
}
requestAnimationFrame(attempt)
scheduleDocumentFrame(attempt)
}
requestAnimationFrame(attempt)
scheduleDocumentFrame(attempt)
}
export function commitFitScale(reason: string, attempts: number, gate: string) {
@@ -144,3 +143,11 @@ export function commitFitScale(reason: string, attempts: number, gate: string) {
}
repositionOverlay()
}
/**
* Ruling 21: the retry loop is abandoned by bumping the token it compares itself against, which is
* how it already abandons a superseded attempt.
*/
export function stopFitScale() {
scope.fitRetryToken++
}
@@ -3,8 +3,10 @@ import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-docu
import { XTERM_HTML } from '../terminal-webview-html'
const SCOPE_OPEN = '(function() {\n'
// The first statement the document runs once the scope object exists.
const FIRST_STATEMENT_AFTER_SCOPE = ' scope.surface = document.getElementById'
// The first declaration the document makes once the scope object exists. Ruling 20 left the
// modules below with no top-level statements at all, so the anchor is a declaration rather than
// the surface read that used to open them.
const FIRST_STATEMENT_AFTER_SCOPE = ' function startRuntimeConstants() {'
/**
* The scope object the document opens with. Every block below it reads and writes document state
@@ -1,4 +1,4 @@
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { applyFitScale } from './fit-scale'
import { notify } from './host-notify'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
@@ -49,7 +49,12 @@ export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: n
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function () {
// Ruling 21: a retry that outlives its mount would answer the next mount's measure.
const gen = scope.terminalGeneration
scheduleDocumentFrame(function () {
if (gen !== scope.terminalGeneration) {
return
}
measureFitDimensions(containerHeightPx, retriesLeft - 1)
})
return
+27 -20
View File
@@ -1,5 +1,10 @@
import { scope } from './document-scope'
// Declared beside the seam that hands it out, and re-exported here because this is where the
// document's readers have always named it.
export type { TerminalEngineError } from './document-host-seams'
import type { TerminalEngineError } from './document-host-seams'
/**
* The postMessage bridge to the host, and the engine error reporting that rides on it.
*
@@ -14,14 +19,9 @@ declare global {
}
export function notify(msg: Record<string, unknown>) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg))
}
scope.postToHost(msg)
}
/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */
export type TerminalEngineError = string | null | undefined | { message?: unknown }
export function engineErrorText(err: TerminalEngineError) {
if (!err) {
return ''
@@ -44,15 +44,13 @@ export function chromeVersionText() {
return match ? 'Chrome ' + match[1] : 'Chrome version unknown'
}
let nonFatalErrorNotifies = 0
export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) {
const isFatal = fatal === undefined ? !scope.everReady : !!fatal
if (!isFatal) {
// Why: a constructed-but-degraded engine can throw per frame; cap
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
nonFatalErrorNotifies++
if (nonFatalErrorNotifies > 5) {
scope.nonFatalErrorNotifies++
if (scope.nonFatalErrorNotifies > 5) {
return
}
}
@@ -72,15 +70,24 @@ export function reportEngineError(context: string, err: TerminalEngineError, fat
})
}
window.onerror = function (
msg: string | (Event & { message?: unknown }),
source,
line,
column,
err?: TerminalEngineError
) {
if (window.__engineErrors.length < 20) {
window.__engineErrors.push(String(msg))
export function startHostNotify() {
scope.uninstallErrorReporter = scope.installErrorReporter(function (
msg: string | (Event & { message?: unknown }),
source,
line,
column,
err?: TerminalEngineError
) {
if (window.__engineErrors.length < 20) {
window.__engineErrors.push(String(msg))
}
reportEngineError('terminal runtime error', err || msg)
})
}
export function stopHostNotify() {
if (scope.uninstallErrorReporter) {
scope.uninstallErrorReporter()
scope.uninstallErrorReporter = null
}
reportEngineError('terminal runtime error', err || msg)
}
@@ -0,0 +1,263 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { TerminalDocumentScope } from './document-scope'
/**
* The six host seams the page sets, and the window reads and writes they default to.
*
* The document reached its host through `window.ReactNativeWebView` and built its engine from
* `window.Terminal` and the two addon globals the engine bundle installs. On the page neither is
* available the way the document assumes: `window.ReactNativeWebView` is the *shell's* bridge, so
* a terminal notify posted through it would put raw terminal JSON into the bridge's own channel,
* and there is no engine bundle at all because the page imports xterm as a module.
*
* So each of the six is a scope field. The default is the window read the document already did,
* unchanged and still performed at call time rather than captured when the scope is built; the
* page assigns the field instead. Both halves are asserted here, because a seam whose default
* quietly stopped reading the window would leave the native document mute with every other
* terminal test still green — they stub those globals and would be stubbing nothing.
*/
const SURFACE_MARKUP =
'<div id="terminal-container"><div id="terminal-surface"></div></div>' +
'<div id="selection-overlay"><div id="sel-handle-start"></div>' +
'<div id="sel-handle-end"></div><div id="sel-menu">' +
'<button id="sel-menu-copy"></button><button id="sel-menu-all"></button></div></div>' +
'<div id="scroll-indicator"><div id="scroll-thumb"></div></div>'
// Imported after the markup exists: ruling 20 leaves the module bodies inert, but the start
// sequence below reads the elements as the document does, and it has to find them.
let createTerminalDocumentScope: () => TerminalDocumentScope
let scope: TerminalDocumentScope
let handleMsg: typeof import('./host-message-router').handleMsg
let notify: typeof import('./host-notify').notify
let flog: typeof import('./viewport-transform').flog
let attachWebglAddon: typeof import('./webgl-recovery').attachWebglAddon
beforeAll(async () => {
document.body.innerHTML = SURFACE_MARKUP
// The page's own entry and the page's own sequence, rather than a hand-picked subset: the
// elements `runtime-constants`, `surface-swap` and `selection-state-and-eviction` take are read
// in the one order both hosts run them in, and a module added to that order is covered here
// without this file being edited.
const pageModules = await import('./page-document-modules')
pageModules.startPageDocumentModules()
const documentScope = await import('./document-scope')
createTerminalDocumentScope = documentScope.createTerminalDocumentScope
scope = documentScope.scope
;({ handleMsg } = await import('./host-message-router'))
;({ notify } = await import('./host-notify'))
;({ flog } = await import('./viewport-transform'))
;({ attachWebglAddon } = await import('./webgl-recovery'))
})
function terminalDouble() {
const loaded: unknown[] = []
let opened: HTMLElement | undefined
const terminal = {
cols: 80,
rows: 24,
options: { theme: {}, minimumContrastRatio: 3, fontSize: 13 },
buffer: { active: { baseY: 0, viewportY: 0, cursorY: 0, length: 1, type: 'normal' } },
get element() {
return opened
},
unicode: { activeVersion: '6' },
loaded,
write(_data: string, callback?: () => void) {
callback?.()
},
open(element: HTMLElement) {
opened = element
},
loadAddon: (addon: unknown) => loaded.push(addon),
attachCustomKeyEventHandler() {},
onData: () => ({ dispose() {} }),
onLineFeed: () => ({ dispose() {} }),
onScroll: () => ({ dispose() {} }),
onWriteParsed: () => ({ dispose() {} }),
clear() {},
reset() {},
refresh() {},
resize() {},
selectAll() {},
select() {},
clearSelection() {},
scrollLines() {},
scrollToLine() {},
scrollToBottom() {},
dispose() {}
}
return terminal
}
/** Restores every field a case assigns, so one of them cannot leave the singleton scope moved. */
function withSeams(seams: Partial<TerminalDocumentScope>, run: () => void) {
const previous: Record<string, unknown> = {}
for (const key of Object.keys(seams)) {
previous[key] = Object.getOwnPropertyDescriptor(scope, key)?.value
}
Object.assign(scope, seams)
try {
run()
} finally {
Object.assign(scope, previous)
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('the document host seams, by default', () => {
it('posts to the React Native bridge, reading it at call time', () => {
const postMessage = vi.fn<(data: string) => void>()
// Built before the global exists: the default must read the window when it posts, not when
// the scope was created, because the document's scope is built as its script is parsed.
const built = createTerminalDocumentScope()
vi.stubGlobal('ReactNativeWebView', { postMessage })
built.postToHost({ type: 'ready', cols: 80, rows: 24 })
expect(postMessage.mock.calls).toEqual([['{"type":"ready","cols":80,"rows":24}']])
})
it('posts nothing when there is no bridge, which is the guard the document carried', () => {
expect(() => createTerminalDocumentScope().postToHost({ type: 'ready' })).not.toThrow()
})
it('builds the terminal from the engine bundle global', () => {
const constructed: Record<string, unknown>[] = []
function TerminalStub(this: unknown, options: Record<string, unknown>) {
constructed.push(options)
}
vi.stubGlobal('Terminal', TerminalStub)
const term = createTerminalDocumentScope().createTerminal({ cols: 80, rows: 24 })
expect(constructed).toEqual([{ cols: 80, rows: 24 }])
expect(term).toBeInstanceOf(TerminalStub)
})
it('installs the runtime error reporter by taking window.onerror, and hands back its undo', () => {
const previous = window.onerror
try {
const report = () => {}
const uninstall = createTerminalDocumentScope().installErrorReporter(report)
expect(window.onerror).toBe(report)
// Ruling 20 made the install a per-mount act, so the seam owes the caller a way back.
uninstall()
expect(window.onerror).toBe(null)
} finally {
window.onerror = previous
}
})
it('paints the document roots, which is what owning the page means', () => {
// Inside the WebView the terminal's theme is the page's own background, so the document sets
// it on `html` and `body`. On the page those belong to the application, which is why this is
// a field: the render check holds that neither root moves while a terminal is mounted.
const roots = [document.documentElement, document.body]
const previous = roots.map((element) => element.style.background)
try {
createTerminalDocumentScope().paintDocumentBackground('rgb(1, 2, 3)')
expect(roots.map((element) => element.style.background)).toEqual([
'rgb(1, 2, 3)',
'rgb(1, 2, 3)'
])
} finally {
roots.forEach((element, index) => {
element.style.background = previous[index]!
})
}
})
it('builds each addon from its engine global, and answers null when the engine has none', () => {
const built = createTerminalDocumentScope()
expect(built.createUnicode11Addon()).toBe(null)
expect(built.createWebglAddon()).toBe(null)
class Unicode11Addon {
dispose() {}
}
class WebglAddon {
dispose() {}
}
vi.stubGlobal('Unicode11Addon', { Unicode11Addon })
vi.stubGlobal('WebglAddon', { WebglAddon })
expect(built.createUnicode11Addon()).toBeInstanceOf(Unicode11Addon)
expect(built.createWebglAddon()).toBeInstanceOf(WebglAddon)
})
})
describe('the document host seams, once the page sets them', () => {
it('routes every notify to the field and nothing to the bridge', () => {
const postMessage = vi.fn<(data: string) => void>()
vi.stubGlobal('ReactNativeWebView', { postMessage })
const posted: Record<string, unknown>[] = []
withSeams({ postToHost: (message) => posted.push(message) }, () => {
notify({ type: 'pong', pingId: 7 })
flog('probe', { n: 1 })
})
expect(posted).toEqual([
{ type: 'pong', pingId: 7 },
{ type: 'log', tag: '[fit]probe', payload: { n: 1 } }
])
// The whole reason the seam exists: on the page this object belongs to the shell.
expect(postMessage).not.toHaveBeenCalled()
})
it('routes a host message into the document and builds the engine from the fields', () => {
const terminal = terminalDouble()
const options: Record<string, unknown>[] = []
const unicodeAddon = { dispose() {} }
const webglAddon = { dispose() {} }
const posted: Record<string, unknown>[] = []
withSeams(
{
createTerminal: (created) => {
options.push(created)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the double implements every member `init` reaches; the calls below are what check it.
return terminal as unknown as ReturnType<typeof scope.createTerminal>
},
createUnicode11Addon: () => unicodeAddon,
createWebglAddon: () => webglAddon,
postToHost: (message) => posted.push(message)
},
() => {
handleMsg({ type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false })
expect(options).toHaveLength(1)
expect(options[0]!.cols).toBe(80)
expect(terminal.loaded).toContain(webglAddon)
expect(terminal.loaded).toContain(unicodeAddon)
expect(terminal.unicode.activeVersion).toBe('11')
// The other direction: a document-side report reaches the page's sink, not the bridge.
handleMsg({ type: 'ping', id: 3 })
expect(posted).toContainEqual({ type: 'pong', pingId: 3 })
}
)
})
it('leaves window.onerror alone when the host installs the reporter its own way', () => {
// The page's case, which is the whole reason this one is a field: on a page that object is
// not the terminal's to take. A host that installs its reporter elsewhere must leave it null.
const previous = window.onerror
window.onerror = null
const installed: unknown[] = []
try {
const built = createTerminalDocumentScope()
const undos: unknown[] = []
built.installErrorReporter = (report) => {
installed.push(report)
return () => undos.push(report)
}
built.installErrorReporter(() => {})()
expect(installed).toHaveLength(1)
expect(undos).toHaveLength(1)
expect(window.onerror).toBe(null)
} finally {
window.onerror = previous
}
})
it('reports no webgl addon as a DOM-renderer fallback rather than as a failure', () => {
withSeams({ createWebglAddon: () => null }, () => {
expect(attachWebglAddon(true)).toBe(false)
})
})
})
+19 -20
View File
@@ -30,24 +30,23 @@ export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage |
}
}
window.addEventListener('message', handleIncomingMessage)
document.addEventListener('message', handleIncomingMessage)
window.addEventListener('resize', function () {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize')
adjustRowsForViewport()
repositionOverlay()
clampPan()
updateTransform()
})
if (window.Terminal) {
notify({ type: 'web-ready' })
} else {
reportEngineError('terminal engine missing', 'xterm failed to load', true)
export function startMessageBridge() {
window.addEventListener('message', handleIncomingMessage)
document.addEventListener('message', handleIncomingMessage)
window.addEventListener('resize', function () {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize')
adjustRowsForViewport()
repositionOverlay()
clampPan()
updateTransform()
})
if (window.Terminal) {
notify({ type: 'web-ready' })
} else {
reportEngineError('terminal engine missing', 'xterm failed to load', true)
}
}
@@ -37,10 +37,3 @@ export function emitModesIfChanged() {
})
}
}
scope.lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
@@ -20,8 +20,6 @@ export type TerminalMouseGesture = {
dismissedSelection: boolean
}
let mouseGesture: TerminalMouseGesture | null = null
// One report per transition, built with the same encoding ladder as
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
// when the mode does not report this transition (x10 has no release, only
@@ -81,8 +79,8 @@ export function mouseReportCellKey(clientX: number, clientY: number) {
}
export function abandonMouseGesture() {
const gesture = mouseGesture
mouseGesture = null
const gesture = scope.mouseGesture
scope.mouseGesture = null
if (!gesture) {
return
}
@@ -141,7 +139,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
}
// Why: a pointerup lost outside the WebView must not leave the previous
// gesture latched (tracking press with no release) when the next one lands.
if (mouseGesture) {
if (scope.mouseGesture) {
abandonMouseGesture()
}
// Why: mouse pointers have no implicit capture; without it a drag that
@@ -151,7 +149,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.setPointerCapture(e.pointerId)
}
} catch {}
mouseGesture = {
scope.mouseGesture = {
startX: e.clientX,
startY: e.clientY,
lastX: e.clientX,
@@ -165,7 +163,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
// Why: touch parity — pressing outside the pill dismisses the current
// selection; the same press may still start a new drag selection.
cancelSelect()
mouseGesture.dismissedSelection = true
scope.mouseGesture.dismissedSelection = true
}
},
true
@@ -174,7 +172,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'pointermove',
function (e) {
const gesture = mouseGesture
const gesture = scope.mouseGesture
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') {
return
}
@@ -220,11 +218,11 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'pointerup',
function (e) {
const gesture = mouseGesture
const gesture = scope.mouseGesture
if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) {
return
}
mouseGesture = null
scope.mouseGesture = null
if (gesture.mode === 'cancelled' || !scope.term) {
return
}
@@ -274,7 +272,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'touchstart',
function () {
if (mouseGesture) {
if (scope.mouseGesture) {
abandonMouseGesture()
}
},
@@ -1,6 +1,6 @@
import { getCellHeight } from './fit-scale'
import { getTotalScale, updateScrollIndicator } from './viewport-transform'
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
export function clampNormalScrollLines(lines: number) {
if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) {
@@ -77,7 +77,7 @@ export function enqueueNormalBufferScrollDelta(deltaY: number) {
// Why: dense terminal rows are expensive to repaint. Coalesce touchmove
// deltas into one xterm row-scroll per frame instead of repainting from
// the input event stream.
scope.normalScrollFrameId = requestAnimationFrame(function () {
scope.normalScrollFrameId = scheduleDocumentFrame(function () {
scope.normalScrollFrameId = null
const delta = scope.pendingNormalScrollDeltaY
scope.pendingNormalScrollDeltaY = 0
@@ -100,3 +100,8 @@ export function resetSmoothScrollOffset() {
scope.smoothScrollOffsetY = 0
updateScrollIndicator(false)
}
/** Ruling 21: the smooth-scroll frame, which would otherwise scroll the next mount's buffer. */
export function stopNormalBufferSmoothScroll() {
resetSmoothScrollOffset()
}
@@ -0,0 +1,77 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { terminalDocumentStartCalls } from '../../../scripts/build-terminal-document-script.mjs'
import {
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
} from '../../../scripts/terminal-document-module-order.mjs'
/**
* The page runs the document's modules in the order the WebView's script runs them.
*
* It has to: the document is one function scope, so `runtime-constants` taking the surface before
* `surface-swap` captures it is not a dependency the graph records. Inside the WebView the
* generator reads the order from one file; on the page the order is the import list in
* `page-document-modules.ts`, and nothing but this holds the two together. A formatter that sorted
* that list, or a module added to the generator and not to the page, would leave both sides green
* and the page running a different program.
*
* Read as text rather than by importing the module, because importing it would run the document
* against an empty body and prove only that the file parses.
*/
const pageEntry = readFileSync(new URL('./page-document-modules.ts', import.meta.url), 'utf8')
/** `message-bridge` is ruling 19's exclusion: on the page those frames belong to the shell. */
const EXCLUDED = ['message-bridge']
function importedModules(): string[] {
return [...pageEntry.matchAll(/^import (?:\{[^}]*\} from )?'\.\/([a-z0-9-]+)'$/gm)].map(
(match) => match[1]!
)
}
describe('the page entry for the terminal document', () => {
it('imports every module the generator emits, in the same order, minus the bridge', () => {
expect(importedModules()).toEqual([
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER.filter((name) => !EXCLUDED.includes(name))
])
})
it('names its exclusion, and the exclusion is a module the generator does emit', () => {
for (const name of EXCLUDED) {
expect(TERMINAL_DOCUMENT_MODULE_ORDER).toContain(name)
expect(importedModules()).not.toContain(name)
}
})
it('calls the same start sequence the generated document calls, minus the bridge', async () => {
// Ruling 20's other half. The import list above only proves the page reaches the same
// modules; what runs is the call sequence, and the generator writes its own from the same
// sources. A module that grows a start function and is not called here would leave the page
// with an element nobody read.
const sequence = [...pageEntry.matchAll(/^ {2,4}(start[A-Za-z]+)\(\)$/gm)].map(
(match) => match[1]!
)
const emitted = await terminalDocumentStartCalls([
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER
])
expect(sequence.length).toBeGreaterThan(0)
expect(sequence).toEqual(emitted.filter((name) => name !== 'startMessageBridge'))
})
it('would report a reordered list', () => {
// The precondition for the first case: a matcher that found nothing would agree with an empty
// expectation just as happily. Swapping the first two names must break it.
const [first, second, ...rest] = importedModules()
expect([second, first, ...rest]).not.toEqual([
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER.filter((name) => !EXCLUDED.includes(name))
])
})
})
@@ -0,0 +1,116 @@
/**
* The document's modules, and the two sequences that start and stop them.
*
* The document is one function scope, not a dependency graph: `runtime-constants` takes the
* surface, `surface-swap` captures the surface it was handed, and `selection-state-and-eviction`
* takes the overlay elements. Ruling 20 moved those out of the module bodies into start
* functions; ruling 21 moved every mutable binding onto the scope, so what is left at module top
* level is constants, functions and types. Importing this file therefore does nothing at all.
*
* That is what makes a second mount a second terminal. ES module bodies run once per page, so a
* remount re-imports nothing: it resets the scope, then runs the same start sequence the WebView's
* generated script runs once at parse, against the markup the host has just replanted.
*
* `message-bridge` is deliberately absent (ruling 19). It installs `window`/`document` `message`
* listeners, and on the page those frames belong to the shell: the document would read a bridge
* envelope as a terminal command. The component calls `handleMsg` instead, and re-arms the one
* other thing that module does, the window-resize refit.
*
* `page-document-module-order.test.ts` holds these lists against
* `scripts/terminal-document-module-order.mjs`, so the page and the WebView cannot run different
* programs and a reordering edit cannot pass unread.
*/
import './document-host-seams'
import { cancelDocumentFrames, resetTerminalDocumentScope } from './document-scope'
import { startRuntimeConstants } from './runtime-constants'
import './query-reply'
import { startSurfaceSwap } from './surface-swap'
import { startTextScaling } from './text-scaling'
import { stopViewportTransform } from './viewport-transform'
import './terminal-theme'
import { stopFitScale } from './fit-scale'
import './mouse-mode-decset-scan'
import './write-queue'
import { startWebglRecovery, stopWebglRecovery } from './webgl-recovery'
import { stopTerminalInit } from './terminal-init'
import './reflow'
import { startHostNotify, stopHostNotify } from './host-notify'
import './host-message-router'
import { startSelectionStateAndEviction } from './selection-state-and-eviction'
import './mode-mirroring'
import './keyboard-avoidance-metrics'
import './term-observers'
import './viewport-cell'
import './mouse-report-cell'
import './mouse-input-encoding'
import { stopNormalBufferSmoothScroll } from './normal-buffer-smooth-scroll'
import './cell-geometry'
import './path-tap'
import './url-tap'
import './osc-link-tap'
import './surface-tap'
import './selection-range'
import { stopSelectionOverlay } from './selection-overlay'
import { startTapDispatch, stopTapDispatch } from './tap-dispatch'
import './wheel-scroll'
import './mouse-click-drag'
import { startSelectionMenuButtons } from './selection-menu-buttons'
import { startSurfaceTouchGestures, stopSurfaceTouchGestures } from './surface-touch-gestures'
/**
* The scope's reset and every module's start function, in module order: what the WebView's
* document runs once as its script is parsed, run here once per mount.
*/
export function startPageDocumentModules() {
resetTerminalDocumentScope()
// Unwound if one of them throws: a start that completed has already taken a listener or
// installed the reporter, and leaving those behind would outlive the mount that never happened.
// Only the starts with an undo need recording; the rest write scope fields the next reset
// overwrites.
const undo: (() => void)[] = []
try {
startRuntimeConstants()
startSurfaceSwap()
startTextScaling()
startWebglRecovery()
undo.unshift(stopWebglRecovery)
startHostNotify()
undo.unshift(stopHostNotify)
startSelectionStateAndEviction()
startTapDispatch()
undo.unshift(stopTapDispatch)
startSelectionMenuButtons()
startSurfaceTouchGestures()
undo.unshift(stopSurfaceTouchGestures)
} catch (error) {
for (const stop of undo) {
stop()
}
throw error
}
}
/**
* The undo, in reverse module order: every listener that outlives the host element, and every
* frame, timer and retry a module scheduled (ruling 21).
*/
export function stopPageDocumentModules() {
// Last frames first: a module's own stop nulls the handle it holds, and this takes back every
// frame the document is still owed, including the ones no module tracks by id.
cancelDocumentFrames()
stopSurfaceTouchGestures()
stopTapDispatch()
stopSelectionOverlay()
stopNormalBufferSmoothScroll()
stopHostNotify()
stopTerminalInit()
stopWebglRecovery()
stopFitScale()
stopViewportTransform()
}
export { scope } from './document-scope'
export { handleMsg } from './host-message-router'
export { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale'
export { repositionOverlay } from './selection-overlay'
export { updateTransform } from './viewport-transform'
@@ -0,0 +1,60 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
/**
* A start sequence that throws leaves nothing of itself behind.
*
* The starts are not all writes to the scope: `startHostNotify` installs the host's error
* reporter and `startTapDispatch` takes four document listeners. If one of the later starts
* throws, the mount fails and its handle releases the page — but the reporter and the listeners
* are already installed, and nothing else would reach them: the next mount's reset nulls the undo
* the install handed back, so the listener would stay for the life of the tab.
*
* The provocation is the document's own markup with the selection menu missing, which is what
* `startSelectionMenuButtons` reads and the only thing it does.
*/
const MARKUP_WITHOUT_THE_MENU =
'<div id="terminal-container"><div id="terminal-surface"></div></div>' +
'<div id="selection-overlay"><div id="sel-handle-start"></div>' +
'<div id="sel-handle-end"></div></div>' +
'<div id="scroll-indicator"><div id="scroll-thumb"></div></div>'
describe('the page start sequence', () => {
it('unwinds the starts that completed when a later one throws', async () => {
document.body.innerHTML = MARKUP_WITHOUT_THE_MENU
const { startPageDocumentModules } = await import('./page-document-modules')
const previous = window.onerror
window.onerror = null
try {
expect(() => startPageDocumentModules()).toThrow()
// `startHostNotify` ran and installed the default reporter, which takes `window.onerror`.
// The unwind is the only thing that gives it back: the next mount's reset nulls the undo it
// handed out, so an install left standing here is permanent.
expect(window.onerror).toBe(null)
} finally {
window.onerror = previous
}
})
it('would have installed one, so the null above is a measurement', async () => {
// The precondition. With the menu present the same sequence completes, and the reporter it
// installs is exactly what the case above asserts was taken back.
document.body.innerHTML = MARKUP_WITHOUT_THE_MENU.replace(
'<div id="sel-handle-end"></div></div>',
'<div id="sel-handle-end"></div><div id="sel-menu">' +
'<button id="sel-menu-copy"></button><button id="sel-menu-all"></button></div></div>'
)
const { startPageDocumentModules, stopPageDocumentModules } =
await import('./page-document-modules')
const previous = window.onerror
window.onerror = null
try {
startPageDocumentModules()
expect(window.onerror).not.toBe(null)
stopPageDocumentModules()
expect(window.onerror).toBe(null)
} finally {
window.onerror = previous
}
})
})
+4 -8
View File
@@ -18,20 +18,16 @@ export type QueryReplyTerminal = {
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
}
// Written from four places, all of them here, so it is this module's state rather than the
// document's and stays a local.
let terminalDataRepliesEnabled = false
export function resetTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = false
scope.terminalDataRepliesEnabled = false
}
export function resumeTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = true
scope.terminalDataRepliesEnabled = true
}
export function forwardTerminalDataReply(data: string) {
if (terminalDataRepliesEnabled) {
if (scope.terminalDataRepliesEnabled) {
notify({ type: 'terminal-data', bytes: data })
}
}
@@ -39,7 +35,7 @@ export function forwardTerminalDataReply(data: string) {
export function enqueueTerminalDataReplyBoundary(gen: number) {
enqueueWriteBoundary(function () {
if (gen === scope.terminalGeneration) {
terminalDataRepliesEnabled = true
scope.terminalDataRepliesEnabled = true
}
})
}
@@ -6,18 +6,7 @@ import { scope } from './document-scope'
* All eight are read by other parts of the script, so all eight are scope fields; the document
* shell opens the function they live in and `document-close.ts` closes it.
*/
scope.surface = document.getElementById('terminal-surface')
scope.ESC = String.fromCharCode(27)
scope.C1_CSI = String.fromCharCode(155)
scope.CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)
scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)
scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)
scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp(
scope.CLAUDE_STATUS_DOT +
'[' +
scope.TEXT_PRESENTATION_SELECTOR +
scope.EMOJI_PRESENTATION_SELECTOR +
']*',
'g'
)
scope.statusDotPendingSelector = false
export function startRuntimeConstants() {
scope.surface = document.getElementById('terminal-surface')
}
@@ -3,34 +3,35 @@ import { notify } from './host-notify'
import { cancelSelect } from './selection-range'
import { repositionOverlay } from './selection-overlay'
scope.btnCopy!.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
if (!scope.term) {
return
}
const text = scope.term.getSelection ? scope.term.getSelection() : ''
if (text && text.length > 0) {
notify({ type: 'selection', text: text })
} else {
cancelSelect()
}
})
scope.btnSelAll!.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
if (!scope.term) {
return
}
try {
scope.term.selectAll()
const b = scope.term.buffer.active
scope.sel = {
anchor: { col: 0, row: 0 },
focus: { col: scope.term.cols - 1, row: b.length - 1 },
activeHandle: null
export function startSelectionMenuButtons() {
scope.btnCopy!.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
if (!scope.term) {
return
}
repositionOverlay()
} catch {}
})
const text = scope.term.getSelection ? scope.term.getSelection() : ''
if (text && text.length > 0) {
notify({ type: 'selection', text: text })
} else {
cancelSelect()
}
})
scope.btnSelAll!.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
if (!scope.term) {
return
}
try {
scope.term.selectAll()
const b = scope.term.buffer.active
scope.sel = {
anchor: { col: 0, row: 0 },
focus: { col: scope.term.cols - 1, row: b.length - 1 },
activeHandle: null
}
repositionOverlay()
} catch {}
})
}
@@ -139,3 +139,8 @@ export function handleDragMove(handle: string, clientX: number, clientY: number)
}
// Latching document-level touch dispatcher: see tap-dispatch.ts.
/** Ruling 21: the edge-scroll interval, which outlives the selection that started it. */
export function stopSelectionOverlay() {
stopEdgeScroll()
}
@@ -6,55 +6,36 @@ import { scope } from './document-scope'
// ============================================================
// SELECTION MODE (long-press → handles → Copy)
// ============================================================
scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u
scope.LONG_PRESS_MS = 500
scope.LONG_PRESS_SLOP = 10
// Why: a tap that opens a link/path must survive small finger jitter. The
// long-press slop (10px) only cancels the press-to-select timer; reusing it
// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
// window so deliberate scrolls/pans still don't fire a tap.
scope.TAP_SLOP = 24
scope.TAP_MAX_MS = 700
scope.EDGE_SCROLL_PX = 40
scope.EDGE_SCROLL_INTERVAL = 60
scope.selectionOverlay = document.getElementById('selection-overlay')
scope.handleStart = document.getElementById('sel-handle-start')
scope.handleEnd = document.getElementById('sel-handle-end')
scope.selMenu = document.getElementById('sel-menu')
scope.btnCopy = document.getElementById('sel-menu-copy')
scope.btnSelAll = document.getElementById('sel-menu-all')
// mode: 'navigate' | 'select'
scope.selMode = 'navigate'
scope.sel = null // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
scope.longPressTimer = null
scope.longPressOrigin = null // {x,y, identifier}
// { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
// {x,y, identifier}
// Why: tap detection is tracked separately from the long-press timer so a
// small jitter that cancels the press-to-select timer does not also cancel
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
// gesture is disqualified as a tap (moved too far or held too long).
scope.tapCandidate = null
scope.edgeScrollTimer = null
scope.edgeScrollDir = 0
scope.edgeScrollClientX = 0
scope.edgeScrollClientY = 0
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Eviction watchdog: linesEverWritten counts onLineFeed since the last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
let linesEverWritten = 0
export function resetEvictionCounter() {
linesEverWritten = 0
scope.linesEverWritten = 0
}
export function isBufferFull() {
if (!scope.term) {
return false
}
return linesEverWritten >= 5000 + (scope.term.rows || 0)
return scope.linesEverWritten >= 5000 + (scope.term.rows || 0)
}
export function checkEviction() {
@@ -69,7 +50,7 @@ export function checkEviction() {
}
export function logFeedAndEvict() {
linesEverWritten++
scope.linesEverWritten++
if (scope.initialOscLinkEvictionReady && isBufferFull()) {
scope.initialOscLinkRowOffset += 1
}
@@ -80,3 +61,12 @@ export function logFeedAndEvict() {
repositionOverlay()
}
}
export function startSelectionStateAndEviction() {
scope.selectionOverlay = document.getElementById('selection-overlay')
scope.handleStart = document.getElementById('sel-handle-start')
scope.handleEnd = document.getElementById('sel-handle-end')
scope.selMenu = document.getElementById('sel-menu')
scope.btnCopy = document.getElementById('sel-menu-copy')
scope.btnSelAll = document.getElementById('sel-menu-all')
}
+16 -16
View File
@@ -9,31 +9,24 @@ export type TerminalSurfaceSwap = {
nextSurface: HTMLElement
}
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
let committedTerm: TerminalDocumentTerminal | null = null
let committedSurface = scope.surface
scope.pendingTerm = null
let pendingSurface: HTMLElement | null = null
export function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
if (scope.pendingSurface) {
try {
pendingSurface.remove()
scope.pendingSurface.remove()
} catch {}
if (scope.pendingTerm) {
try {
scope.pendingTerm.dispose()
} catch {}
}
pendingSurface = null
scope.pendingSurface = null
scope.pendingTerm = null
}
const swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
oldTerm: scope.committedTerm,
oldSurface: scope.committedSurface,
nextSurface: document.createElement('div')
}
disposeTermObservers()
@@ -44,7 +37,7 @@ export function beginTerminalSurfaceSwap() {
swap.nextSurface.style.top = '0'
document.getElementById('terminal-container')!.appendChild(swap.nextSurface)
scope.surface = swap.nextSurface
pendingSurface = swap.nextSurface
scope.pendingSurface = swap.nextSurface
attachSurfaceEventHandlers(scope.surface)
swap.oldSurface!.removeAttribute('id')
return swap
@@ -62,8 +55,15 @@ export function commitTerminalSurfaceSwap(
if (swap.oldTerm) {
swap.oldTerm.dispose()
}
committedTerm = nextTerm
committedSurface = swap.nextSurface
scope.committedTerm = nextTerm
scope.committedSurface = swap.nextSurface
scope.pendingTerm = null
pendingSurface = null
scope.pendingSurface = null
}
// Why: phone-fit startup can issue several init() calls before xterm finishes replaying, so the
// last painted surface is tracked apart from its replacement — on the scope (ruling 21), because
// the page mounts this module more than once and a second mount must not inherit the first's.
export function startSurfaceSwap() {
scope.committedSurface = scope.surface
}
@@ -1,4 +1,4 @@
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { clampPan, getCellHeight } from './fit-scale'
import { notify } from './host-notify'
import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag'
@@ -17,7 +17,7 @@ import { attachSurfaceWheelHandler } from './wheel-scroll'
type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean }
/** The live touch gesture: the last point, the velocity, and the pinch it may be in. */
type TerminalTouchState = {
export type TerminalTouchState = {
lastX: number
lastY: number
lastTime: number
@@ -31,20 +31,6 @@ type TerminalTouchState = {
pinchSurfY: number
}
const ts: TerminalTouchState = {
lastX: 0,
lastY: 0,
lastTime: 0,
velY: 0,
accumDelta: 0,
momentumId: null,
isPinching: false,
pinchDist: 0,
pinchScale: 0,
pinchSurfX: 0,
pinchSurfY: 0
}
export function updateTouchVelocity(deltaY: number, dt: number) {
if (dt <= 0) {
return
@@ -55,7 +41,10 @@ export function updateTouchVelocity(deltaY: number, dt: number) {
}
// Why: touchmove cadence is uneven in WebView. Blend recent samples so
// momentum launch doesn't inherit a one-frame spike or stall.
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45
scope.touchGesture.velY =
scope.touchGesture.velY === 0
? instantVelocity
: scope.touchGesture.velY * 0.55 + instantVelocity * 0.45
}
export function getDistance(a: Touch, b: Touch) {
@@ -97,27 +86,27 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
if (dispatcherShouldBlockSurface()) {
return
}
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId)
ts.momentumId = null
if (scope.touchGesture.momentumId) {
cancelAnimationFrame(scope.touchGesture.momentumId)
scope.touchGesture.momentumId = null
}
if (e.touches.length === 2) {
ts.isPinching = true
scope.touchGesture.isPinching = true
scope.smoothScrollOffsetY = 0
ts.pinchDist = getDistance(e.touches[0], e.touches[1])
ts.pinchScale = scope.userScale
scope.touchGesture.pinchDist = getDistance(e.touches[0], e.touches[1])
scope.touchGesture.pinchScale = scope.userScale
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
const total = getTotalScale()
ts.pinchSurfX = (mx - scope.panX) / total
ts.pinchSurfY = (my - scope.panY) / total
scope.touchGesture.pinchSurfX = (mx - scope.panX) / total
scope.touchGesture.pinchSurfY = (my - scope.panY) / total
} else if (e.touches.length === 1) {
ts.isPinching = false
ts.lastX = e.touches[0].clientX
ts.lastY = e.touches[0].clientY
ts.lastTime = Date.now()
ts.velY = 0
ts.accumDelta = 0
scope.touchGesture.isPinching = false
scope.touchGesture.lastX = e.touches[0].clientX
scope.touchGesture.lastY = e.touches[0].clientY
scope.touchGesture.lastTime = Date.now()
scope.touchGesture.velY = 0
scope.touchGesture.accumDelta = 0
}
},
{ capture: true, passive: true }
@@ -136,28 +125,31 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
e.stopPropagation()
if (e.touches.length === 2) {
ts.isPinching = true
scope.touchGesture.isPinching = true
const dist = getDistance(e.touches[0], e.touches[1])
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
const ratio = dist / ts.pinchDist
const ratio = dist / scope.touchGesture.pinchDist
// Why: userScale is a CSS multiplier on the current font size; bound it so
// the resulting apparent size (currentTextScale × userScale) stays within
// the preset range, since release snaps to one of those presets.
const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale
const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale
scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio))
scope.userScale = Math.max(
loScale,
Math.min(hiScale, scope.touchGesture.pinchScale * ratio)
)
const total = getTotalScale()
scope.panX = mx - ts.pinchSurfX * total
scope.panY = my - ts.pinchSurfY * total
scope.panX = mx - scope.touchGesture.pinchSurfX * total
scope.panY = my - scope.touchGesture.pinchSurfY * total
clampPan()
updateTransform()
} else if (e.touches.length === 1 && !ts.isPinching) {
} else if (e.touches.length === 1 && !scope.touchGesture.isPinching) {
const x = e.touches[0].clientX,
y = e.touches[0].clientY
const now = Date.now(),
dt = now - ts.lastTime
dt = now - scope.touchGesture.lastTime
// Why: pan horizontally only when content overflows the viewport (larger
// than fit) — same check clampPan() uses. Vertical always drives buffer
@@ -168,32 +160,32 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
scope.term.element &&
scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1
) {
scope.panX += x - ts.lastX
scope.panX += x - scope.touchGesture.lastX
clampPan()
updateTransform()
}
const deltaY = ts.lastY - y
ts.lastTime = now
const deltaY = scope.touchGesture.lastY - y
scope.touchGesture.lastTime = now
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt)
resetSmoothScrollOffset()
const effectiveCellH = getCellHeight() * getTotalScale()
ts.accumDelta += deltaY
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
scope.touchGesture.accumDelta += deltaY
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH)
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH
scope.touchGesture.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, x, y)
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt)
} else {
ts.velY = 0
scope.touchGesture.velY = 0
}
}
ts.lastX = x
ts.lastY = y
scope.touchGesture.lastX = x
scope.touchGesture.lastY = y
}
},
{ capture: true, passive: false }
@@ -209,8 +201,8 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
return
}
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false
if (scope.touchGesture.isPinching && e.touches.length < 2) {
scope.touchGesture.isPinching = false
// Why: a finished pinch snaps to the nearest preset and becomes the new
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
@@ -227,45 +219,45 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
notify({ type: 'haptic', kind: 'selection' })
}
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX
ts.lastY = e.touches[0].clientY
ts.lastTime = Date.now()
ts.velY = 0
ts.accumDelta = 0
scope.touchGesture.lastX = e.touches[0].clientX
scope.touchGesture.lastY = e.touches[0].clientY
scope.touchGesture.lastTime = Date.now()
scope.touchGesture.velY = 0
scope.touchGesture.accumDelta = 0
}
return
}
if (e.touches.length === 0) {
let vel = ts.velY
let vel = scope.touchGesture.velY
const FRICTION = 0.972
const MIN_VEL = 0.012
function momentumStep() {
vel *= FRICTION
if (Math.abs(vel) < MIN_VEL) {
ts.momentumId = null
scope.touchGesture.momentumId = null
return
}
const delta = vel * 16
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset()
const effectiveCellH = getCellHeight() * getTotalScale()
ts.accumDelta += delta
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
scope.touchGesture.accumDelta += delta
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH)
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, ts.lastX, ts.lastY)
scope.touchGesture.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY)
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null
scope.touchGesture.momentumId = null
return
}
}
ts.momentumId = requestAnimationFrame(momentumStep)
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep)
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep)
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep)
}
}
},
@@ -273,4 +265,14 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
)
}
attachSurfaceEventHandlers(scope.surface!)
export function startSurfaceTouchGestures() {
attachSurfaceEventHandlers(scope.surface!)
}
/** Ruling 21: the momentum loop, which would keep scrolling into the terminal that replaced it. */
export function stopSurfaceTouchGestures() {
if (scope.touchGesture.momentumId !== null) {
cancelAnimationFrame(scope.touchGesture.momentumId)
scope.touchGesture.momentumId = null
}
}
+160 -155
View File
@@ -20,13 +20,6 @@ export type TerminalTouchDispatch = {
/** An element a target can be tested against; a method so a real element satisfies it. */
type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean }
const dispatch: TerminalTouchDispatch = {
mode: 'idle',
touchId: null,
touchIds: null,
longPressFingerInsideOverlay: false
}
export function touchById(touches: TouchList, id: number | null) {
for (let i = 0; i < touches.length; i++) {
if (touches[i].identifier === id) {
@@ -81,168 +74,180 @@ export function touchSlopExceeded(t: Touch) {
// Why: existing surface handlers stay attached to surface but we wrap
// their entry to no-op when the dispatcher latches into select-drag.
export function dispatcherShouldBlockSurface() {
return dispatch.mode === 'select-drag'
return scope.touchDispatch.mode === 'select-drag'
}
document.addEventListener(
'touchstart',
function (e) {
const t = e.touches[0]
const target = e.target
const onHandle = target === scope.handleStart || target === scope.handleEnd
const inOverlay = targetInside(target, scope.selectionOverlay)
const inSurface = targetInside(target, scope.surface)
// Why: clear any stale tap candidate up front; only a fresh single-finger
// surface touch (below) re-arms it, so handle drags / pinches / dismiss
// taps never resolve as a link tap on touchend.
scope.tapCandidate = null
/**
* The options each document handler is registered with, named so `stopTapDispatch` takes it off
* with the identical `capture` flag it went on with.
*/
const CAPTURE_ACTIVE = { capture: true, passive: false }
const CAPTURE_PASSIVE = { capture: true, passive: true }
if (e.touches.length === 2) {
// pinch latch
if (scope.selMode === 'select') {
notify({ type: 'mobile-clip-cancel-by-pinch' })
cancelSelect()
}
dispatch.mode = 'pinch'
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]
clearLongPress()
return
}
function onDocumentTouchStart(e: TouchEvent) {
const t = e.touches[0]
const target = e.target
const onHandle = target === scope.handleStart || target === scope.handleEnd
const inOverlay = targetInside(target, scope.selectionOverlay)
const inSurface = targetInside(target, scope.surface)
// Why: clear any stale tap candidate up front; only a fresh single-finger
// surface touch (below) re-arms it, so handle drags / pinches / dismiss
// taps never resolve as a link tap on touchend.
scope.tapCandidate = null
if (onHandle && scope.selMode === 'select') {
// start handle drag
const handleName = target === scope.handleStart ? 'start' : 'end'
scope.sel!.activeHandle = handleName
dispatch.mode = 'select-drag'
dispatch.touchId = t.identifier
e.preventDefault()
return
}
if (inOverlay) {
// tap on menu pill — let the buttons' own handlers fire
return
}
if (inSurface && scope.selMode === 'select') {
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
// selection clears it. We cancel immediately and latch to 'surface' so
// the same gesture still drives scroll/pan without a second touch.
if (e.touches.length === 2) {
// pinch latch
if (scope.selMode === 'select') {
notify({ type: 'mobile-clip-cancel-by-pinch' })
cancelSelect()
dispatch.mode = 'surface'
dispatch.touchId = t.identifier
}
scope.touchDispatch.mode = 'pinch'
scope.touchDispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]
clearLongPress()
return
}
if (onHandle && scope.selMode === 'select') {
// start handle drag
const handleName = target === scope.handleStart ? 'start' : 'end'
scope.sel!.activeHandle = handleName
scope.touchDispatch.mode = 'select-drag'
scope.touchDispatch.touchId = t.identifier
e.preventDefault()
return
}
if (inOverlay) {
// tap on menu pill — let the buttons' own handlers fire
return
}
if (inSurface && scope.selMode === 'select') {
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
// selection clears it. We cancel immediately and latch to 'surface' so
// the same gesture still drives scroll/pan without a second touch.
cancelSelect()
scope.touchDispatch.mode = 'surface'
scope.touchDispatch.touchId = t.identifier
return
}
if (inSurface) {
scope.touchDispatch.mode = 'surface'
scope.touchDispatch.touchId = t.identifier
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }
armLongPress(t)
}
}
function onDocumentTouchMove(e: TouchEvent) {
if (scope.touchDispatch.mode === 'select-drag') {
const t = touchById(e.touches, scope.touchDispatch.touchId)
if (!t || !scope.sel || !scope.sel.activeHandle) {
return
}
if (inSurface) {
dispatch.mode = 'surface'
dispatch.touchId = t.identifier
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }
armLongPress(t)
}
},
{ capture: true, passive: false }
)
document.addEventListener(
'touchmove',
function (e) {
if (dispatch.mode === 'select-drag') {
const t = touchById(e.touches, dispatch.touchId)
if (!t || !scope.sel || !scope.sel.activeHandle) {
return
e.preventDefault()
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY)
return
}
if (scope.touchDispatch.mode === 'surface' || scope.touchDispatch.mode === 'pinch') {
// long-press slop check
if (scope.longPressTimer && e.touches.length === 1) {
if (touchSlopExceeded(e.touches[0])) {
clearLongPress()
}
e.preventDefault()
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY)
return
}
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
// long-press slop check
if (scope.longPressTimer && e.touches.length === 1) {
if (touchSlopExceeded(e.touches[0])) {
clearLongPress()
// Why: disqualify the tap only once the finger travels past TAP_SLOP
// (a scroll/pan), independent of the long-press timer — so a tap that
// jitters under TAP_SLOP still opens the link/path under the finger.
if (scope.tapCandidate && e.touches.length === 1) {
const mt = e.touches[0]
if (mt.identifier === scope.tapCandidate.identifier) {
const dx = Math.abs(mt.clientX - scope.tapCandidate.x)
const dy = Math.abs(mt.clientY - scope.tapCandidate.y)
if (dx + dy > scope.TAP_SLOP) {
scope.tapCandidate = null
}
}
// Why: disqualify the tap only once the finger travels past TAP_SLOP
// (a scroll/pan), independent of the long-press timer — so a tap that
// jitters under TAP_SLOP still opens the link/path under the finger.
if (scope.tapCandidate && e.touches.length === 1) {
const mt = e.touches[0]
if (mt.identifier === scope.tapCandidate.identifier) {
const dx = Math.abs(mt.clientX - scope.tapCandidate.x)
const dy = Math.abs(mt.clientY - scope.tapCandidate.y)
if (dx + dy > scope.TAP_SLOP) {
scope.tapCandidate = null
}
}
} else if (e.touches.length !== 1) {
scope.tapCandidate = null
}
// existing surface handler will run from its own listener
}
},
{ capture: true, passive: false }
)
document.addEventListener(
'touchend',
function (e) {
if (dispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
stopEdgeScroll()
dispatch.mode = 'idle'
dispatch.touchId = null
return
}
if (dispatch.mode === 'pinch') {
if (e.touches.length < 2) {
dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle'
dispatch.touchIds = null
if (e.touches.length === 1) {
dispatch.touchId = e.touches[0].identifier
}
}
return
}
if (dispatch.mode === 'surface') {
// Why: fire the tap from the tap-candidate origin (survives jitter under
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
// can null mid-tap — that was dropping URL/file taps that moved a few px.
if (
e.touches.length === 0 &&
scope.tapCandidate &&
scope.selMode !== 'select' &&
Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS
) {
notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)
}
clearLongPress()
} else if (e.touches.length !== 1) {
scope.tapCandidate = null
if (e.touches.length === 0) {
dispatch.mode = 'idle'
dispatch.touchId = null
}
// existing surface handler will run from its own listener
}
}
function onDocumentTouchEnd(e: TouchEvent) {
if (scope.touchDispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
stopEdgeScroll()
scope.touchDispatch.mode = 'idle'
scope.touchDispatch.touchId = null
return
}
if (scope.touchDispatch.mode === 'pinch') {
if (e.touches.length < 2) {
scope.touchDispatch.mode = e.touches.length === 1 ? 'surface' : 'idle'
scope.touchDispatch.touchIds = null
if (e.touches.length === 1) {
scope.touchDispatch.touchId = e.touches[0].identifier
}
}
},
{ capture: true, passive: true }
)
document.addEventListener(
'touchcancel',
function () {
return
}
if (scope.touchDispatch.mode === 'surface') {
// Why: fire the tap from the tap-candidate origin (survives jitter under
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
// can null mid-tap — that was dropping URL/file taps that moved a few px.
if (
e.touches.length === 0 &&
scope.tapCandidate &&
scope.selMode !== 'select' &&
Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS
) {
notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)
}
clearLongPress()
scope.tapCandidate = null
stopEdgeScroll()
if (dispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
if (e.touches.length === 0) {
scope.touchDispatch.mode = 'idle'
scope.touchDispatch.touchId = null
}
dispatch.mode = 'idle'
dispatch.touchId = null
dispatch.touchIds = null
},
{ capture: true, passive: true }
)
}
}
function onDocumentTouchCancel() {
clearLongPress()
scope.tapCandidate = null
stopEdgeScroll()
if (scope.touchDispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
}
scope.touchDispatch.mode = 'idle'
scope.touchDispatch.touchId = null
scope.touchDispatch.touchIds = null
}
/**
* The dispatcher's four document listeners, per mount (ruling 20).
*
* They are on `document` rather than on the surface, so unlike every surface handler they outlive
* the host element a remount replaces — which is exactly why the undo below exists.
*/
export function startTapDispatch() {
document.addEventListener('touchstart', onDocumentTouchStart, CAPTURE_ACTIVE)
document.addEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE)
document.addEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE)
document.addEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE)
}
export function stopTapDispatch() {
document.removeEventListener('touchstart', onDocumentTouchStart, CAPTURE_ACTIVE)
document.removeEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE)
document.removeEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE)
document.removeEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE)
clearLongPress()
}
@@ -1,391 +0,0 @@
import {
describeToken,
readScriptTokens,
type DocumentToken
} from './terminal-document-tokens.test-support'
/**
* Whether two versions of the in-WebView document script are the same program, allowing only the
* scope qualifier that moving it into modules requires.
*
* C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. A variable
* the script assigns across what became a module boundary cannot stay a free variable — assigning
* an imported binding is a syntax error — so those become fields of one scope object, 73
* declaration sites in all, and every read and write of them gains a qualifier. Nothing else about
* the program may change.
*
* Byte comparison cannot make that claim once the source is formatter-owned: `oxfmt` writes the
* repository's style, which drops the semicolons the hand-written document carries, so the emitted
* text necessarily differs on almost every line for reasons that are not the refactor. Tokens are
* the level where the claim is exactly true. Semicolons are excluded for the same reason they moved
* — they are the formatter's, not the program's — and comments never reach the stream.
*
* This is deliberately stricter than "it still runs": a reordered statement, a changed literal, a
* dropped `!`, a renamed local, all diverge here and are reported with the token index and both
* sides, so the flip commit is reviewed by running this rather than by reading a 515-line diff.
*/
/**
* The differences moving the script into modules is allowed to make, each counted on its own.
*
* Eight classes and no others. Six are the repository's own rules and the printer rewriting the
* document's ES5 style the moment its source is a linted module — measured over the whole script,
* not assumed: `curly` braces 279 brace-less bodies, `no-unused-vars` unbinds 36 catch clauses, 373
* `var` declarators become `const` or `let`, `unicorn/prefer-number-properties` moves 17 globals
* onto `Number`, the printer spells out 4 shorthand properties whose value gained a qualifier, and
* it stops renaming 7 bindings that are no longer shadows. The other two are the move itself: 609
* qualified references and 73 declarations onto the scope. Semicolons and whitespace are the
* formatter's and never reach the token stream at all.
*
* Counted separately because the flip commit pins each number: a total would let one class absorb
* another, which is exactly the drift the pin exists to catch.
*/
export type TerminalDocumentNormalisations = {
/** `name` became `<qualifier>.name`; the declaration stayed where it was. */
readonly qualifiedReferences: number
/**
* `var name` became `<qualifier>.name`; the declaration moved onto the scope object. A `var`
* with several declarators counts once per declarator, because each becomes its own assignment.
*/
readonly scopeFieldDeclarations: number
/** `var` became `const` or `let`, the binding staying local to the emitted script. */
readonly rebindings: number
/** A brace-less `if`/`else`/`for`/`while` body gained its braces. */
readonly bracedBodies: number
/** `catch (e)` became `catch`, the unused binding dropped. */
readonly unboundCatches: number
/** A global numeric function became its `Number` property. */
readonly numberProperties: number
/** `{ name: name }` was shorthand; qualifying the value spells the property out again. */
readonly shorthandProperties: number
/**
* An inner binding that shadowed a document variable stopped being a shadow once that variable
* moved onto the scope, so the printer stopped renaming it.
*/
readonly unshadowedNames: number
}
/**
* The bindings the printer renamed on the baseline and leaves alone in the modules, listed.
*
* A parameter named for a document variable shadowed it while both lived in one function scope, so
* the printer gave the inner one a decimal suffix; once the outer name is a scope field there is no
* shadow and the inner one keeps its own name. Listed rather than matched by shape: a rule that
* accepted any `name2` facing `name` would also accept an unrelated rename that happens to end in a
* digit, which is a changed program, not a normalisation.
*
* One entry covers all seven sites the whole script has: the `term` parameter of
* `attachTerminalQueryReplyBridge` in `query-reply.ts` and its six uses.
*/
const UNSHADOWED_RENAMES: readonly {
readonly baseline: string
readonly generated: string
readonly module: string
}[] = [{ baseline: 'term2', generated: 'term', module: 'query-reply' }]
/** Whether this exact baseline-to-generated pair is one of the listed unshadowed renames. */
function isListedUnshadowedRename(baseline: string, generated: string): boolean {
return UNSHADOWED_RENAMES.some(
(entry) => entry.baseline === baseline && entry.generated === generated
)
}
/**
* The globals `unicorn/prefer-number-properties` moves onto `Number`.
*
* Measured over the whole script: seventeen sites, and the rule is the only one of its kind that
* appears often enough to be worth matching. Each is equivalent here because every call is already
* behind a `typeof … === 'number'` check or is parsing a string, which is what the `Number` form
* does with no coercion of its own.
*/
const NUMBER_GLOBALS = new Set(['isFinite', 'isNaN', 'parseInt', 'parseFloat'])
export type TerminalDocumentEquivalence =
| { readonly equivalent: true; readonly normalisations: TerminalDocumentNormalisations }
| { readonly equivalent: false; readonly reason: string }
/**
* `baseline` is the script as it stood before the move, `candidate` the one the modules generate.
*
* The qualifier is read from `qualifier`, not assumed, so the test names the object it expects and
* a rename cannot quietly satisfy this.
*/
/** The statement heads `curly` braces: everything whose body may be a single unbraced statement. */
const BRACEABLE_HEAD_KEYWORDS = new Set(['if', 'for', 'while'])
/**
* Whether the `{` at `open` is the body of a braceable head rather than some other block.
*
* `else` and `do` are followed by their body directly. The rest put a parenthesised head first, so
* the `)` is walked back to its `(` and the keyword before that is what decides. Without this a
* bare block anywhere in the generated script would be absorbed as a linter-added body, when it is
* a statement the baseline does not have.
*/
function isBraceableHeadBody(tokens: readonly DocumentToken[], open: number): boolean {
const previous = tokens[open - 1]
if (previous === undefined) {
return false
}
if (previous.label === 'else' || previous.label === 'do') {
return true
}
if (previous.label !== ')') {
return false
}
let depth = 0
for (let i = open - 1; i >= 0; i--) {
const label = tokens[i]?.label
if (label === ')') {
depth += 1
continue
}
if (label === '(') {
depth -= 1
if (depth === 0) {
return BRACEABLE_HEAD_KEYWORDS.has(tokens[i - 1]?.label ?? '')
}
}
}
return false
}
/** The index of the `}` closing the `{` at `open`, or -1 when the generated script has none. */
function matchingCloseIndex(tokens: readonly DocumentToken[], open: number): number {
let depth = 0
for (let i = open; i < tokens.length; i++) {
const label = tokens[i]?.label
if (label === '{') {
depth += 1
continue
}
if (label === '}') {
depth -= 1
if (depth === 0) {
return i
}
}
}
return -1
}
export function compareTerminalDocumentScripts(
baseline: string,
candidate: string,
qualifier: string
): TerminalDocumentEquivalence {
const baselineTokens = readScriptTokens(baseline, 'the baseline')
if (!baselineTokens.ok) {
return { equivalent: false, reason: baselineTokens.reason }
}
const candidateTokens = readScriptTokens(candidate, 'the generated script')
if (!candidateTokens.ok) {
return { equivalent: false, reason: candidateTokens.reason }
}
const before = baselineTokens.tokens
const after = candidateTokens.tokens
let qualifiedReferences = 0
let scopeFieldDeclarations = 0
let rebindings = 0
let bracedBodies = 0
let unboundCatches = 0
let numberProperties = 0
let shorthandProperties = 0
let unshadowedNames = 0
// The generated index each inserted `{` expects its `}` at, innermost last. Recording the index
// rather than counting means an absorbed close is the one that closes that body and no other.
const insertedBraceCloses: number[] = []
let lastMatched: DocumentToken | undefined
let left = 0
let right = 0
while (left < before.length && right < after.length) {
const expected = before[left]
const actual = after[right]
// Ahead of the equality check on purpose: the baseline's next token is a `}` too wherever a
// braced body ends a block, and this index is known to close the inserted body, so matching
// them as a pair would consume the wrong one and leave the counts right for the wrong reason.
if (actual.label === '}' && insertedBraceCloses.at(-1) === right) {
insertedBraceCloses.pop()
right += 1
continue
}
if (expected.label === actual.label && expected.text === actual.text) {
lastMatched = expected
left += 1
right += 1
continue
}
// `term2` -> `term`: the printer disambiguated a shadowed binding on the baseline side, and
// qualifying the outer name removed the shadow, so the inner one keeps its own name.
if (
expected.label === 'name' &&
actual.label === 'name' &&
isListedUnshadowedRename(expected.text, actual.text)
) {
unshadowedNames += 1
lastMatched = actual
left += 1
right += 1
continue
}
// `{ name }` -> `{ name: <qualifier>.name }`: the printer writes the baseline's shorthand back
// as one token, and qualifying the value makes the property name unavoidable again.
if (
actual.label === ':' &&
lastMatched?.label === 'name' &&
after[right + 1]?.label === 'name' &&
after[right + 1]?.text === qualifier &&
after[right + 2]?.label === '.' &&
after[right + 3]?.text === lastMatched.text
) {
shorthandProperties += 1
right += 4
continue
}
// `name` -> `<qualifier>.name`, three tokens for one.
if (isQualified(after, right, expected, qualifier)) {
qualifiedReferences += 1
left += 1
right += 3
continue
}
// `parseInt` -> `Number.parseInt`, the same shape under a different object.
if (NUMBER_GLOBALS.has(expected.text) && isQualified(after, right, expected, 'Number')) {
numberProperties += 1
left += 1
right += 3
continue
}
// `var name` -> `<qualifier>.name`: the declaration itself moved onto the scope object.
if (
expected.label === 'var' &&
before[left + 1] !== undefined &&
isQualified(after, right, before[left + 1], qualifier)
) {
scopeFieldDeclarations += 1
left += 2
right += 3
continue
}
// `var a = 1, b = 2` where both moved onto the scope: the comma introduces the second
// declaration, which is written as its own assignment.
if (
expected.label === ',' &&
before[left + 1] !== undefined &&
isQualified(after, right, before[left + 1], qualifier)
) {
scopeFieldDeclarations += 1
left += 2
right += 3
continue
}
if (expected.label === 'var' && isBlockScopedKeyword(actual)) {
rebindings += 1
lastMatched = actual
left += 1
right += 1
continue
}
// `catch (e) {` -> `catch {`: three baseline tokens the linted form does not carry.
if (
lastMatched?.label === 'catch' &&
expected.label === '(' &&
before[left + 1]?.label === 'name' &&
before[left + 2]?.label === ')' &&
actual.label === '{'
) {
unboundCatches += 1
left += 3
continue
}
// `if (a) b;` -> `if (a) { b; }`: the body the repository's `curly` rule braced. Only a
// braceable head's body qualifies, and only that body's own close is absorbed.
if (actual.label === '{' && isBraceableHeadBody(after, right)) {
const close = matchingCloseIndex(after, right)
if (close !== -1) {
bracedBodies += 1
insertedBraceCloses.push(close)
right += 1
continue
}
}
return {
equivalent: false,
reason: `token ${left}: expected ${describeToken(expected)}, generated ${describeToken(actual)}`
}
}
// A body braced at the very end of the script leaves its close after the baseline has run out.
while (insertedBraceCloses.at(-1) === right && after[right]?.label === '}') {
insertedBraceCloses.pop()
right += 1
}
if (left !== before.length || right !== after.length) {
return {
equivalent: false,
reason: `length: ${before.length - left} token(s) left in the baseline, ${after.length - right} in the generated script`
}
}
if (insertedBraceCloses.length !== 0) {
return {
equivalent: false,
reason: `${insertedBraceCloses.length} inserted brace(s) never closed`
}
}
return {
equivalent: true,
normalisations: {
qualifiedReferences,
scopeFieldDeclarations,
rebindings,
bracedBodies,
unboundCatches,
numberProperties,
shorthandProperties,
unshadowedNames
}
}
}
/**
* Whether a token is the `const` or `let` a `var` became.
*
* `let` is contextual outside strict mode, so acorn reports it as a name rather than as a keyword;
* matching on the label alone would refuse every `let` the linter introduced.
*/
function isBlockScopedKeyword(token: DocumentToken): boolean {
return token.label === 'const' || (token.label === 'name' && token.text === 'let')
}
/** Whether the generated stream reads `<qualifier>.<expected>` where the baseline read `expected`. */
function isQualified(
after: DocumentToken[],
right: number,
expected: DocumentToken,
qualifier: string
): boolean {
return (
after[right]?.label === 'name' &&
after[right]?.text === qualifier &&
after[right + 1]?.label === '.' &&
after[right + 2]?.label === expected.label &&
after[right + 2]?.text === expected.text
)
}
/**
* The hand-written script out of the whole document, which is the part C7.1 moves.
*
* Read by locating the generated engine rather than by an index into the text, so a slice added
* above or below it does not silently shift what gets compared.
*/
export function readTerminalDocumentScript(document: string, engineJs: string): string {
const opener = `<script>${engineJs}</script>`
const start = document.indexOf(opener)
if (start === -1) {
throw new Error('the document does not carry the generated engine script')
}
const scriptStart = document.indexOf('<script>', start + opener.length)
const scriptEnd = document.lastIndexOf('</script>')
if (scriptStart === -1 || scriptEnd <= scriptStart) {
throw new Error('the document does not carry a hand-written script after the engine')
}
return document.slice(scriptStart + '<script>'.length, scriptEnd)
}
@@ -1,225 +0,0 @@
import { describe, expect, it } from 'vitest'
import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
import { XTERM_HTML } from '../terminal-webview-html'
import {
compareTerminalDocumentScripts,
readTerminalDocumentScript,
type TerminalDocumentNormalisations
} from './terminal-document-equivalence.test-support'
/**
* The instrument the C7.1 flip commit is reviewed with, exercised on what it will be asked.
*
* Each refusal below is one way the move could go wrong, and they matter more than the
* acceptances: a comparison that let a reordered statement or a changed literal through would pass
* the flip while the document had quietly become a different program.
*/
const QUALIFIER = 'scope'
const script = readTerminalDocumentScript(XTERM_HTML, XTERM_ENGINE_JS)
const NONE: TerminalDocumentNormalisations = {
qualifiedReferences: 0,
scopeFieldDeclarations: 0,
rebindings: 0,
bracedBodies: 0,
unboundCatches: 0,
numberProperties: 0,
shorthandProperties: 0,
unshadowedNames: 0
}
function normalisationsOf(before: string, after: string): TerminalDocumentNormalisations | string {
const result = compareTerminalDocumentScripts(before, after, QUALIFIER)
return result.equivalent ? result.normalisations : result.reason
}
describe('terminal document script equivalence', () => {
it('reads the hand-written script out of the real document', () => {
expect(script.trimStart().startsWith('(function() {')).toBe(true)
expect(script.trimEnd().endsWith('})();')).toBe(true)
})
it('accepts the real script against itself, normalising nothing', () => {
// The instrument on the real 2,758-line program rather than on a toy, which is the only way to
// know it survives everything the document actually contains.
expect(normalisationsOf(script, script)).toEqual(NONE)
})
it('ignores the semicolons the formatter drops and the comments it keeps', () => {
const before = '// one\nvar a = 1;\nfunction f() {\n b(a);\n}\n'
const after = '/* other */\nvar a = 1\nfunction f() {\n b(a)\n}\n'
expect(normalisationsOf(before, after)).toEqual(NONE)
})
it('counts a reference that gained the qualifier', () => {
expect(
normalisationsOf(
'function f() { return a + a; }',
'function f() { return scope.a + scope.a }'
)
).toEqual({ ...NONE, qualifiedReferences: 2 })
})
it('counts a declaration that moved onto the scope object', () => {
// `var a = 1` and `a = 1` are different sites: one dropped a `var`, the other never had one.
expect(normalisationsOf('var a = 1;\na = 2;', 'scope.a = 1\nscope.a = 2')).toEqual({
...NONE,
scopeFieldDeclarations: 1,
qualifiedReferences: 1
})
})
it('counts a var that stayed local and only changed keyword', () => {
expect(normalisationsOf('var a = 1;', 'const a = 1')).toEqual({ ...NONE, rebindings: 1 })
})
it('counts a var that became a let, which acorn reports as a name', () => {
// `let` is contextual outside strict mode, so a rule matching on the token label alone would
// refuse every reassigned local the linter rewrote.
expect(normalisationsOf('var a = 1;\na = 2;', 'let a = 1\na = 2')).toEqual({
...NONE,
rebindings: 1
})
})
it('counts braces the linter adds to a brace-less body', () => {
const before = 'if (a) b();\nfor (;;) c();\n'
const after = 'if (a) {\n b()\n}\nfor (;;) {\n c()\n}\n'
expect(normalisationsOf(before, after)).toEqual({ ...NONE, bracedBodies: 2 })
})
it('counts a catch clause the linter unbound', () => {
expect(normalisationsOf('try { a(); } catch (e) {}', 'try {\n a()\n} catch {}')).toEqual({
...NONE,
unboundCatches: 1,
numberProperties: 0,
shorthandProperties: 0,
unshadowedNames: 0
})
})
it('counts a global numeric function the linter moved onto Number', () => {
expect(
normalisationsOf('if (isFinite(a)) b();', 'if (Number.isFinite(a)) {\n b()\n}')
).toEqual({
...NONE,
numberProperties: 1,
bracedBodies: 1
})
})
it('refuses a global the linter does not move, qualified as if it did', () => {
// Only the four numeric globals are this rewrite; anything else under `Number` is a change.
expect(normalisationsOf('a = setTimeout(f);', 'a = Number.setTimeout(f)')).toContain('token 2')
})
it('refuses a qualifier under a name it was not told to expect', () => {
expect(normalisationsOf('a = 1;', 'state.a = 1')).toContain('token 0')
})
it('counts a shorthand property the qualifier had to spell out', () => {
expect(
normalisationsOf('var o = { alt: alt, n: 1 };', 'var o = { alt: scope.alt, n: 1 };')
).toEqual({ ...NONE, shorthandProperties: 1 })
})
it('counts each declarator of one var that moved onto the scope', () => {
expect(normalisationsOf('var a = 1, b = 2;', 'scope.a = 1; scope.b = 2;')).toEqual({
...NONE,
scopeFieldDeclarations: 2
})
})
it('reads a block-scoped function declaration the same way on both sides', () => {
const script = 'function f() { if (a) { function g() { return 1; } return g(); } }'
expect(normalisationsOf(script, script)).toEqual(NONE)
})
it('counts a name the printer no longer has to disambiguate', () => {
expect(
normalisationsOf(
'var term = null; function f(term) { return term; }',
'scope.term = null; function f(term) { return term; }'
)
).toEqual({ ...NONE, scopeFieldDeclarations: 1, unshadowedNames: 2 })
})
it('refuses a numeric-suffix rename that is not a listed unshadowed binding', () => {
// The shape `value2` -> `value` is what the printer does to a shadow, but this pair is not one
// of the document's, so it is a renamed local: a changed program, not a normalisation.
expect(
normalisationsOf('function f() { return value2; }', 'function f() { return value; }')
).toBe('token 6: expected name value2, generated name value')
})
it('refuses a bare block the baseline does not have', () => {
// A block that is nobody's body cannot be the `curly` rule's work, so absorbing it would hide
// a statement boundary the baseline never had.
expect(normalisationsOf('let value = 1; use(value);', '{ let value = 1; } use(value);')).toBe(
'token 0: expected name let, generated {'
)
})
it('counts a braced body only for a head that can carry an unbraced one', () => {
expect(normalisationsOf('if (a) b();', 'if (a) { b(); }')).toEqual({
...NONE,
bracedBodies: 1
})
expect(normalisationsOf('for (;;) b();', 'for (;;) { b(); }')).toEqual({
...NONE,
bracedBodies: 1
})
expect(normalisationsOf('while (a) b();', 'while (a) { b(); }')).toEqual({
...NONE,
bracedBodies: 1
})
expect(normalisationsOf('if (a) b(); else c();', 'if (a) { b(); } else { c(); }')).toEqual({
...NONE,
bracedBodies: 2
})
expect(normalisationsOf('do b(); while (a);', 'do { b(); } while (a);')).toEqual({
...NONE,
bracedBodies: 1
})
})
it('refuses a changed literal', () => {
expect(normalisationsOf('var a = 1;', 'var a = 2')).toBe(
'token 3: expected num 1, generated num 2'
)
})
it('refuses a dropped operator', () => {
expect(normalisationsOf('if (!a) return;', 'if (a) return')).toBe(
'token 2: expected !/~ !, generated name a'
)
})
it('refuses a reordered pair of statements', () => {
expect(normalisationsOf('a();\nb();', 'b()\na()')).toContain('token 0')
})
it('refuses a dropped statement', () => {
expect(normalisationsOf('a();\nb();', 'a()')).toBe(
'length: 3 token(s) left in the baseline, 0 in the generated script'
)
})
it('refuses a renamed local', () => {
expect(normalisationsOf('function f(x) { return x; }', 'function f(y) { return y }')).toBe(
'token 3: expected name x, generated name y'
)
})
it('refuses a generated script the printer cannot parse', () => {
// Both sides go through the printer, so something broken is reported here with its own
// message rather than thrown out of the comparison.
expect(normalisationsOf('if (a) b();', 'if (a) {\n b()')).toContain(
'the generated script does not parse'
)
})
it('refuses a baseline the printer cannot parse, naming that side', () => {
expect(normalisationsOf('a()\n}', 'a()')).toContain('the baseline does not parse')
})
})
@@ -1,80 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import {
buildTerminalDocumentScript,
emitTerminalDocumentModule
} from '../../../scripts/build-terminal-document-script.mjs'
import { TERMINAL_DOCUMENT_MODULE_ORDER } from '../../../scripts/terminal-document-module-order.mjs'
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
/**
* The review of the move, as one number per difference class.
*
* `terminal-document-pre-flip-script.txt` is the hand-written script exactly as it stood before any
* of this, taken from the byte fixture that pinned it. This says the modules emit the same program
* modulo the qualifier and the repository's own rules rewriting an ES5 document the moment its
* source is a linted module. Anything outside those classes refuses with the token index and both
* sides, so a reordered statement, a changed literal or a renamed local cannot pass here.
*
* The scope object is the one thing the emitted script has that the document did not, so it is
* pinned on its own below rather than folded into a count.
*
* Retirement, per ruling 18: this test is the proof of the flip and holds only while no module
* changes, so the first lane that must change one retires it together with
* `terminal-document-pre-flip-script.txt`, and the standing pin from then on is
* `terminal-document-identity.test.ts`, whose fixture regeneration is a review event.
*/
const preFlipScript = readFileSync(
new URL('../terminal-document-pre-flip-script.txt', import.meta.url),
'utf8'
)
describe('the whole terminal document script', () => {
it('is what the modules emit, modulo the eight counted classes', async () => {
const emitted = await Promise.all(
TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
)
)
const candidate = `(function() {\n${emitted.join('\n')}\n})();`
expect(compareTerminalDocumentScripts(preFlipScript, candidate, 'scope')).toEqual({
equivalent: true,
normalisations: {
// The qualifier, partitioned: 609 reads and writes of a name whose declaration stayed put,
// and 73 declarations that moved onto the scope object. 682 sites in all.
qualifiedReferences: 609,
scopeFieldDeclarations: 73,
// The document's 446 `var` declarators, less the 73 that became scope fields.
rebindings: 373,
// `curly`, measured over the whole script before any of this started.
bracedBodies: 279,
// Of the document's 38 catch clauses, two name their error and report it, so they keep it.
unboundCatches: 36,
// `unicorn/prefer-number-properties`, also measured up front.
numberProperties: 17,
// Two SGR mode flags written twice each: shorthand cannot survive a qualified value.
shorthandProperties: 4,
// Names the printer had to disambiguate while an outer binding of the same name existed.
unshadowedNames: 7
}
})
})
it('adds the scope object and nothing else', async () => {
const script = await buildTerminalDocumentScript()
const emitted = await Promise.all(
TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
)
)
const body = emitted.join('\n')
const at = script.indexOf(body)
expect(at).toBeGreaterThan(-1)
const preamble = script.slice('(function() {\n'.length, at)
expect(script.slice(at + body.length)).toBe('\n})();')
expect(preamble).toContain('function createTerminalDocumentScope()')
expect(preamble).toContain('const scope = createTerminalDocumentScope();')
expect(preamble.split('createTerminalDocumentScope').length - 1).toBe(2)
})
})
@@ -1,94 +0,0 @@
import { tokenizer } from 'acorn'
import { transformSync } from 'esbuild'
/**
* Reading a version of the in-WebView document script as a token stream.
*
* Kept apart from the comparison that consumes it: this side answers what the script says, and
* says nothing about which differences between two of them are allowed.
*/
/** One token as this comparison reads it: what kind it is, and the text it carried. */
export type DocumentToken = { readonly label: string; readonly text: string }
/**
* Acorn's `Token` class declares `type`, `start` and `end` and not `value`, which it does carry,
* so the field is read through a narrowing check rather than asserted onto the declared type.
*/
function readDocumentToken(token: unknown): DocumentToken | null {
if (typeof token !== 'object' || token === null || !('type' in token) || !('value' in token)) {
return null
}
const type: unknown = token.type
if (typeof type !== 'object' || type === null || !('label' in type)) {
return null
}
const label: unknown = type.label
if (typeof label !== 'string') {
return null
}
const value: unknown = token.value
return { label, text: value === undefined || value === null ? '' : String(value) }
}
/** The directive prepended to both sides, and checked to have survived printing. */
const STRICT_DIRECTIVE = 'use strict'
/**
* Both sides are printed by the generator's own printer before being read.
*
* Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as
* a difference in the program, when it is a difference in who typed it. Printing both sides with
* one printer removes that whole class by construction rather than by a rule per symptom, and
* leaves only what the eight counted classes cover.
*/
function significantTokens(source: string): DocumentToken[] {
// Read strict on both sides. A loose script has to defend Annex B's block-scoped function
// declarations, and the printer does that by hoisting a `var` and renaming the function; a module
// does not, so one side would carry a rename the other cannot. Neither name escapes its block, so
// the two readings agree on behaviour and only the strict one can be compared.
const printed = transformSync(`'${STRICT_DIRECTIVE}';\n${source}`, {
loader: 'js',
target: 'chrome74',
minify: false
}).code
const kept: DocumentToken[] = []
for (const raw of tokenizer(printed, { ecmaVersion: 2020 })) {
const token = readDocumentToken(raw)
if (token === null) {
throw new Error('acorn produced a token this comparison cannot read')
}
if (token.label === ';' || token.label === 'eof') {
continue
}
kept.push(token)
}
if (kept[0]?.text !== STRICT_DIRECTIVE) {
throw new Error('the strict directive this comparison prepends did not survive printing')
}
return kept.slice(1)
}
/**
* The tokens of one side, or the reason it could not be read.
*
* A script that does not parse is a refusal with the printer's own message rather than an
* exception out of the comparison: a generator that emitted something broken should say so where
* the other differences are reported.
*/
export function readScriptTokens(
source: string,
side: string
): { ok: true; tokens: DocumentToken[] } | { ok: false; reason: string } {
try {
return { ok: true, tokens: significantTokens(source) }
} catch (error) {
return {
ok: false,
reason: `${side} does not parse: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`
}
}
}
export function describeToken(token: DocumentToken | undefined): string {
return token === undefined ? '(end of script)' : `${token.label} ${token.text}`.trim()
}
@@ -1,10 +0,0 @@
import { scope } from './document-scope'
/**
* The two fields the document declares before the query-reply bridge that follows it.
*
* They are one module because the emitted document puts them on one line, ahead of an injected
* group; nothing else joins them.
*/
scope.PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
scope.term = null
+17 -19
View File
@@ -7,11 +7,7 @@ import {
} from './document-constants'
import { notify } from './host-notify'
import { fontPxForScale } from './text-scaling'
import {
scope,
type TerminalDocumentTerminal,
type TerminalDocumentWebglAddon
} from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { applyFitScale } from './fit-scale'
import {
isAltScreenActive,
@@ -28,13 +24,6 @@ import { applyTerminalTheme } from './terminal-theme'
import { attachWebglAddon, cancelWebglContextRecovery } from './webgl-recovery'
import { afterWritesDrained, enqueueWrite, pumpWrites, resetWriteQueue } from './write-queue'
declare global {
interface Window {
Unicode11Addon?: { Unicode11Addon: new () => TerminalDocumentWebglAddon }
}
const Terminal: new (options: Record<string, unknown>) => TerminalDocumentTerminal
}
export function init(
cols: number,
rows: number,
@@ -95,7 +84,7 @@ export function init(
const nextSurface = surfaceSwap.nextSurface
applyTerminalTheme(nextTheme)
scope.term = new Terminal({
scope.term = scope.createTerminal({
cols: cols || 80,
rows: rows || 24,
theme: scope.terminalTheme,
@@ -121,12 +110,13 @@ export function init(
scope.pendingTerm = nextTerm
scope.term.open(scope.surface!)
attachWebglAddon(true)
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {
try {
scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon())
try {
const unicodeAddon = scope.createUnicode11Addon()
if (unicodeAddon) {
scope.term.loadAddon(unicodeAddon)
scope.term.unicode.activeVersion = '11'
} catch {}
}
}
} catch {}
if (typeof replayData === 'string' && replayData.length > 0) {
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
enqueueWrite(scope.ESC + '[0m' + replayData)
@@ -138,7 +128,7 @@ export function init(
attachTermObservers()
attachTerminalQueryReplyBridge(scope.term, gen)
requestAnimationFrame(function () {
scheduleDocumentFrame(function () {
if (gen !== scope.terminalGeneration) {
return
}
@@ -199,3 +189,11 @@ export function resize(cols: number, rows: number) {
}
// reflow(): see reflow.ts.
/**
* Ruling 21: init's own frames carry the generation they were scheduled under, so bumping it is
* what abandons them — the same guard a re-init already uses against its predecessor.
*/
export function stopTerminalInit() {
scope.terminalGeneration++
}
@@ -164,8 +164,7 @@ export function applyTerminalTheme(input: TerminalDocumentThemeMessage) {
scope.terminalThemeInput = input
scope.terminalTheme = normalizeTerminalTheme(input)
const background = scope.terminalTheme.background || terminalBackgroundFallback
document.documentElement.style.background = background
document.body.style.background = background
scope.paintDocumentBackground(background)
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
// an older host omits the field and the luminance gate stays authoritative.
const publishedFloor = normalizeTerminalContrastOverride(
+18 -21
View File
@@ -1,37 +1,25 @@
import { terminalTextScalePresets } from './document-constants'
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { applyFitScale, getCellHeight } from './fit-scale'
import { getCellWidth } from './viewport-transform'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
export const scrollIndicator = document.getElementById('scroll-indicator')
export const scrollThumb = document.getElementById('scroll-thumb')
scope.scrollIndicatorHideTimer = null
scope.writeQueue = []
scope.writeQueueHead = 0
scope.writesDraining = false
scope.afterDrainCallbacks = []
scope.termObserverDisposables = []
scope.ready = false
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
scope.everReady = false
scope.currentScale = 1
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
// gesture only; it resets to 1 on release. The persistent "text size" is the
// real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it
// reflows the grid: a bigger cell means fewer columns fit, and RN re-measures
// and resizes the PTY (terminal.updateViewport) so the shell rewraps to the
// new width. A finished pinch snaps to the nearest preset and reports it to RN.
scope.userScale = 1
const BASE_FONT_PX = 13
const MIN_FONT_PX = 6
scope.MIN_FIT_COLS = 20
scope.currentTextScale = 1
const TEXT_SCALE_PRESETS = terminalTextScalePresets
scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]
scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]
export function snapToTextScalePreset(value: number) {
let best = TEXT_SCALE_PRESETS[0],
bestDelta = Infinity
@@ -57,8 +45,7 @@ export function isIOSWebView() {
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
const TERMINAL_FONT_FALLBACKS =
'"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'
scope.terminalFontFamily =
(isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS
// Why: change the real font size, then resize the grid to fit the viewport at
// the new cell metrics so the text shows at its true size immediately. RN's
// refit (measure → updateViewport) then makes the server reflow the PTY to the
@@ -74,8 +61,11 @@ export function applyTextScale(scale: number) {
return
}
scope.term.options.fontSize = px
requestAnimationFrame(function () {
if (!scope.term) {
// Ruling 21: the generation this frame was scheduled under. `scope.term` alone is not enough —
// a mount that came and went leaves a live terminal here, and this would resize that one.
const gen = scope.terminalGeneration
scheduleDocumentFrame(function () {
if (!scope.term || gen !== scope.terminalGeneration) {
return
}
const cellW = getCellWidth()
@@ -92,3 +82,10 @@ export function applyTextScale(scale: number) {
applyFitScale('text-scale')
})
}
export function startTextScaling() {
scope.scrollIndicator = document.getElementById('scroll-indicator')
scope.scrollThumb = document.getElementById('scroll-thumb')
scope.terminalFontFamily =
(isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS
}
@@ -1,56 +1,21 @@
import { terminalDefaultTheme } from './document-constants'
import { repositionOverlay } from './selection-overlay'
import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import { scope } from './document-scope'
import { scrollIndicator, scrollThumb } from './text-scaling'
declare global {
interface Window {
ReactNativeWebView?: { postMessage: (message: string) => void }
}
}
scope.panX = 0
scope.panY = 0
scope.smoothScrollOffsetY = 0
scope.pendingNormalScrollDeltaY = 0
scope.normalScrollFrameId = null
scope.initRows = 24
scope.terminalGeneration = 0
scope.defaultTheme = terminalDefaultTheme
scope.terminalThemeInput = null
scope.terminalTheme = scope.defaultTheme
scope.terminalMinimumContrastRatio = 3
scope.webglAddon = null
scope.webglRecoveryTimer = null
scope.activeAltScreenSnapshot = false
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
scope.initialOscLinks = []
scope.initialOscLinkRowOffset = 0
scope.initialOscLinkEvictionReady = false
scope.mouseModeScanTail = ''
scope.handledMessageIds = []
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
// once when the first live data chunk arrives so a wider line that pushes
// scrollWidth past the previously-measured value gets re-scaled to fit.
scope.firstDataPending = false
// Diagnostic logger — bridges WebView console.log to RN via postMessage.
// Tag with [fit] so it's easy to filter in the Expo/Metro logs.
export function flog(tag: string, payload: Record<string, unknown>) {
try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(
JSON.stringify({
type: 'log',
tag: '[fit]' + tag,
payload: payload
})
)
}
scope.postToHost({
type: 'log',
tag: '[fit]' + tag,
payload: payload
})
} catch {}
}
@@ -104,8 +69,8 @@ export function updateTransform() {
export function updateScrollIndicator(reveal: boolean) {
if (
!scrollIndicator ||
!scrollThumb ||
!scope.scrollIndicator ||
!scope.scrollThumb ||
!scope.term ||
!scope.term.buffer ||
!scope.term.buffer.active
@@ -115,7 +80,7 @@ export function updateScrollIndicator(reveal: boolean) {
const buffer = scope.term.buffer.active
const maxViewportY = buffer.baseY || 0
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
scrollIndicator.classList.remove('visible')
scope.scrollIndicator.classList.remove('visible')
return
}
const trackHeight = Math.max(0, window.innerHeight - 8)
@@ -126,17 +91,25 @@ export function updateScrollIndicator(reveal: boolean) {
const thumbHeight = Math.max(24, (trackHeight * (scope.term.rows || 0)) / totalRows)
const maxTop = Math.max(0, trackHeight - thumbHeight)
const top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0
scrollThumb.style.height = thumbHeight + 'px'
scrollThumb.style.transform = 'translateY(' + top + 'px)'
scope.scrollThumb.style.height = thumbHeight + 'px'
scope.scrollThumb.style.transform = 'translateY(' + top + 'px)'
if (!reveal) {
return
}
scrollIndicator.classList.add('visible')
scope.scrollIndicator.classList.add('visible')
if (scope.scrollIndicatorHideTimer) {
clearTimeout(scope.scrollIndicatorHideTimer)
}
scope.scrollIndicatorHideTimer = setTimeout(function () {
scrollIndicator!.classList.remove('visible')
scope.scrollIndicator!.classList.remove('visible')
scope.scrollIndicatorHideTimer = null
}, 550)
}
/** Ruling 21: the hide timer is the one thing this module schedules. */
export function stopViewportTransform() {
if (scope.scrollIndicatorHideTimer) {
clearTimeout(scope.scrollIndicatorHideTimer)
scope.scrollIndicatorHideTimer = null
}
}
+18 -13
View File
@@ -2,15 +2,6 @@ import { flog } from './viewport-transform'
import { applyTerminalTheme } from './terminal-theme'
import { scope, type TerminalDocumentWebglAddon } from './document-scope'
/** xterm's WebGL addon constructor, as the engine bundle puts it on `window`. */
type WebglAddonGlobal = { WebglAddon?: new () => TerminalDocumentWebglAddon }
declare global {
interface Window {
WebglAddon?: WebglAddonGlobal
}
}
export function refreshTerminalSurface() {
if (!scope.term) {
return
@@ -29,12 +20,17 @@ export function cancelWebglContextRecovery() {
}
export function attachWebglAddon(allowRecovery: boolean) {
if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {
if (!scope.term) {
return false
}
let addon: TerminalDocumentWebglAddon | null = null
try {
addon = new window.WebglAddon.WebglAddon()
addon = scope.createWebglAddon()
// Why: no addon is the DOM renderer, which is a fallback rather than a failure; the
// catch below is for an engine that has one and threw building it.
if (!addon) {
return false
}
scope.webglAddon = addon
if (addon.onContextLoss) {
addon.onContextLoss(function () {
@@ -89,7 +85,7 @@ export function attachWebglAddon(allowRecovery: boolean) {
}
}
document.addEventListener('visibilitychange', function () {
function onDocumentVisibilityChange() {
if (document.visibilityState !== 'visible') {
return
}
@@ -102,4 +98,13 @@ document.addEventListener('visibilitychange', function () {
}
} catch {}
refreshTerminalSurface()
})
}
export function startWebglRecovery() {
document.addEventListener('visibilitychange', onDocumentVisibilityChange)
}
export function stopWebglRecovery() {
document.removeEventListener('visibilitychange', onDocumentVisibilityChange)
cancelWebglContextRecovery()
}
@@ -8,8 +8,6 @@ import {
} from './normal-buffer-smooth-scroll'
import { scope } from './document-scope'
scope.wheelAccumDeltaY = 0
export function wheelEventPixelDeltaY(e: WheelEvent) {
const delta = e.deltaY
if (typeof delta !== 'number' || !Number.isFinite(delta) || delta === 0) {
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,8 @@ import {
TERMINAL_DOCUMENT_FIXTURE_PATH,
terminalDocumentFixture
} from '../../scripts/build-terminal-document-fixture.mjs'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
/**
@@ -23,6 +24,11 @@ import { XTERM_HTML } from './terminal-webview-html'
* kept the document it had. Regenerate the fixture with
* `node scripts/build-terminal-document-fixture.mjs` only when the emitted document was meant to
* change; the diff in that commit is the evidence, and reviewing it is the point.
*
* It is also the only standing pin on the document now. `terminal-document-flip.test.ts` compared
* the modules against the pre-flip script and held exactly while no module changed, so it was the
* proof of the flip rather than a fence; the first lane that had to change a module retired it.
* A golden that moves without its diff listed in the commit message is a blocking finding.
*/
const fixture = readFileSync(TERMINAL_DOCUMENT_FIXTURE_PATH, 'utf8')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
/**
* What a mount whose chunk never arrived is allowed to touch on its way out.
*
* The one path where a mount reaches its own cleanup holding a page that belongs to someone else.
* Everywhere else the build reads the claim again after its import and stops, but a rejected
* import never gets that far: the failure arrives at the mount's error handler directly, and by
* then the overlay's Reload may already have built a second document into the same element. A
* release that emptied the host anyway would blank the terminal on the screen and hand the page
* back while its document ran on.
*
* Its own file because making the import fail is the only way to reach this, and the mock has to
* be in place before the mount module is loaded. It fails once, so the second mount gets the real
* modules and can be a live document to protect.
*/
const { chunk } = vi.hoisted(() => ({ chunk: { failures: 0 } }))
vi.mock('./document/page-document-modules', async (importOriginal) => {
if (chunk.failures === 0) {
chunk.failures += 1
throw new Error('orca-document-chunk-failed')
}
return importOriginal()
})
const { mountTerminalWebDocument } = await import('./terminal-web-document-mount')
const HOST_CLASS = 'orca-terminal-document-host'
describe('a page mount whose document chunk failed', () => {
it('leaves the document that replaced it alone', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
let resizeListeners = 0
const realAdd = window.addEventListener.bind(window)
const realRemove = window.removeEventListener.bind(window)
// Parameters taken from the bound original, so the wrapper carries the real signature rather
// than three implicit `any`s the tests typecheck refuses.
window.addEventListener = (...added: Parameters<typeof realAdd>) => {
resizeListeners += added[0] === 'resize' ? 1 : 0
realAdd(...added)
}
window.removeEventListener = (...removed: Parameters<typeof realRemove>) => {
resizeListeners -= removed[0] === 'resize' ? 1 : 0
realRemove(...removed)
}
// Put back whatever happens, as the sibling case does: a failure part way through would
// otherwise leave the patched functions on `window` for everything that runs after it.
try {
const abandoned = mountTerminalWebDocument(host, () => {})
abandoned.dispose()
// The same element, as React hands it back on the overlay's Reload.
const live = mountTerminalWebDocument(host, () => {})
// The message is the mocking layer's, not the one thrown, so the two counters are what say
// which import did what: the abandoned mount's failed, and the live mount's did not.
await expect(abandoned.ready).rejects.toThrow()
expect(chunk.failures, 'the abandoned mount is the one whose chunk failed').toBe(1)
expect(host.querySelector('#terminal-container')).not.toBe(null)
expect(host.classList.contains(HOST_CLASS)).toBe(true)
// Still claimed, so the release did not hand the page back either.
expect(() => mountTerminalWebDocument(host, () => {})).toThrow(
'the terminal document is already mounted on this page'
)
await live.ready
// The other half of the precondition: the mount that replaced it is a real started document,
// not a second casualty. Its resize listener is the one the start sequence adds.
expect(resizeListeners, 'the live mount started its document').toBe(1)
// And disposing the abandoned handle a second time changes nothing.
abandoned.dispose()
expect(host.querySelector('#terminal-container')).not.toBe(null)
expect(host.classList.contains(HOST_CLASS)).toBe(true)
live.dispose()
expect(host.querySelector('#terminal-container')).toBe(null)
expect(resizeListeners, 'and it took its listener back on the way out').toBe(0)
} finally {
window.addEventListener = realAdd
window.removeEventListener = realRemove
}
})
})
@@ -0,0 +1,308 @@
import { Terminal } from '@xterm/xterm'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
import type { TerminalDocumentWebglAddon } from './document/document-terminal-shape'
import { TERMINAL_DOCUMENT_ELEMENT_STYLE, TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
import { scopeStyleToHost } from './terminal-webview-html/document-style-scoping'
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
/**
* The terminal document, mounted in the page instead of in a WebView.
*
* Same program: the modules the WebView's script is generated from, started here in the order the
* generator emits them. What the WebView's HTML gave them — the stylesheet, the elements they read
* by id, the engine on `window` and a `postMessage` back to React Native — this supplies instead,
* through the six scope seams and the host's own element.
*
* Ruling 20 is what makes a remount work. ES module bodies run once per page, so the second mount
* re-imports nothing: every element read, listener and reporter install lives in a start function,
* and this runs that sequence per mount against the markup it has just replanted. `dispose` takes
* back the three that outlive the host element.
*
* The import is still dynamic, because the page bundle must not carry the document into every
* route that never opens a terminal.
*/
export type TerminalWebDocument = {
/** Hands one host command to the document, as `postMessage` does inside the WebView. */
send: (command: TerminalWebViewCommand & { id: number }) => void
dispose: () => void
/**
* Settles when the document is live, or rejects with what stopped it.
*
* The handle itself is returned before this: the document is reached by a dynamic import, and a
* caller that had to await the import to get a handle would have nothing to dispose while the
* import was in flight. That is not a corner — a slow chunk is what the readiness watchdog is
* for, and the overlay's Reload is what ruling 20 names as the way out of it.
*
* A mount disposed before its import landed resolves rather than rejecting. Nothing failed:
* the caller asked for the terminal and then asked for it to go away, and the chunk arriving
* afterwards is not an error to report. The caller learns which it got from `dispose` being
* the thing it called, not from this.
*/
ready: Promise<void>
}
const STYLE_ELEMENT_ID = 'orca-terminal-document-style'
/** The class the host carries, and the prefix every injected rule is held under. */
const HOST_CLASS = 'orca-terminal-document-host'
/**
* The stylesheet, planted in the head once per page and reaching only inside the host.
*
* `<style>` rather than a constructed sheet or inline attributes: the document's own rules and
* xterm's are written against ids and classes, and the document reads its elements with
* `document.getElementById`, which a shadow root would break.
*
* What is planted is not what the WebView's `<head>` carries. The document-level rules are left
* behind entirely and every remaining selector is prefixed with the host's class, so nothing here
* can match an element the terminal does not own. That is also what makes leaving the sheet in
* the head after unmount the right trade: it matches nothing once the host has dropped the class,
* the next mount wants it back, and re-parsing 11 KiB per mount is all removing it would buy.
* Two terminals at once is not the case — `document-scope` is a module singleton, so there is one
* scope per page and `mount` refuses a second live document rather than letting the two share it.
*/
function ensureDocumentStyle() {
if (document.getElementById(STYLE_ELEMENT_ID)) {
return
}
const style = document.createElement('style')
style.id = STYLE_ELEMENT_ID
const prefix = `.${HOST_CLASS}`
const engine = scopeStyleToHost(XTERM_ENGINE_CSS, prefix)
style.textContent = `${engine}\n${scopeStyleToHost(TERMINAL_DOCUMENT_ELEMENT_STYLE, prefix)}`
document.head.appendChild(style)
}
/**
* The WebGL addon, or null when the browser refuses it.
*
* `webgl-recovery` treats null as the DOM renderer, which is the fallback the document already
* has for a context loss; the page reaches it one step earlier, when the context was never
* granted at all. The caller is told, because a terminal quietly on the slow renderer is worth a
* line in the log rather than a silent halving of the drain rate.
*/
function createPageWebglAddon(onFallback: (reason: string) => void) {
try {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the addon's public surface is `dispose`, which the document's shape names; the two optional members it also reads are absent here and guarded at every call.
return new WebglAddon() as unknown as TerminalDocumentWebglAddon
} catch (error) {
onFallback(error instanceof Error ? error.message : String(error))
return null
}
}
/**
* Which document is live, if any: one per page, because there is one scope per page.
*
* `document-scope` is a module singleton and every module reads it, so a second mount while the
* first is up would not be a second terminal: both would drive the same fields, the same elements
* and the same start sequence. The component mounts and disposes in one effect and cannot reach
* this state, which is exactly why the refusal is named rather than left to surface as two
* terminals writing over each other.
*
* A token per mount rather than the host element or the host's class. Two mounts can be handed
* the same element — the page remounts a terminal into a host React has reused — so an element is
* not an identity, and the class says only that *some* document is using this host. The token is
* what each handle holds, and it is what `dispose` checks before it touches anything shared.
*/
let liveDocument: symbol | null = null
/** What a mount has built so far, which is nothing until its import resolves. */
type StartedDocument = {
modules: typeof import('./document/page-document-modules')
onWindowResize: () => void
}
/**
* The document, mounted. The handle comes back before the document exists.
*
* Synchronous on purpose. The modules arrive through a dynamic import, and the caller's cleanup
* can run while that import is still in flight — a slow chunk, a cold cache, a tab that was
* backgrounded. A caller that had to await the import to get a handle would have nothing to
* dispose in that window, and the claim below would outlive the mount that made it: the next
* mount, the one the error overlay's Reload asks for, would be refused as a second document and
* the terminal would never come back. So the claim and the handle are made here, together, and
* `dispose` answers for whichever state the mount is in when it is called.
*/
export function mountTerminalWebDocument(
host: HTMLElement,
receive: (message: Record<string, unknown>) => void
): TerminalWebDocument {
if (liveDocument) {
throw new Error('the terminal document is already mounted on this page')
}
const token = Symbol('orca terminal document')
liveDocument = token
let started: StartedDocument | null = null
/**
* Gives the page back, and only if it is still this mount's to give.
*
* The check covers the element too, not just the claim. Emptying a host and taking its class
* off are what make the terminal disappear, so a release that skipped the claim but did those
* anyway would blank the terminal a later mount has on the screen. One rule, inside the thing
* it governs, rather than at each caller.
*/
const release = () => {
if (liveDocument !== token) {
return
}
liveDocument = null
host.innerHTML = ''
// The sheet stays in the head; the class does not, so every rule in it matches nothing
// again the moment the terminal is gone.
host.classList.remove(HOST_CLASS)
}
try {
ensureDocumentStyle()
host.classList.add(HOST_CLASS)
host.innerHTML = TERMINAL_DOCUMENT_MARKUP
// The WebView's `<head>` declares this before anything runs, and the document's error
// reporter reads it unguarded. Without it the first report throws inside `window.onerror`.
window.__engineErrors = []
} catch (error) {
// The claim is made before this runs, so it has to come back if the planting fails.
release()
throw error
}
// Adopted by the build itself, in the same turn as the start sequence and the listener it adds,
// rather than when this promise settles. A `.then` runs a microtask later, and a dispose in
// between would find nothing started, skip the teardown and hand the page back with the
// document still running on it.
const ready = buildTerminalWebDocument(host, receive, token, (built) => {
started = built
}).catch((error: unknown) => {
// The import failed, so nothing was started and the page has to go back — the overlay's
// Reload is a second mount and it must be allowed to make one. A later mount may already
// hold the page, which `release` answers for.
release()
throw error
})
return {
send: (command) => {
started?.modules.handleMsg(command)
},
dispose: () => {
// Once, and only by the document that is live. A handle outlives what it built — the
// component holds one in a ref and React may run a cleanup after a later mount has already
// started — so a second call, or a call from a handle whose document has been replaced,
// would tear down the terminal that is on the screen now. Everything below this line is
// shared: the scope, the module sequences, the `window.__engineErrors` array.
if (liveDocument !== token) {
return
}
liveDocument = null
if (started) {
teardownStartedDocument(started)
}
// Dropped, not just torn down. `send` reads this, and the modules it names are the page's one
// singleton — so a handle that kept them would route a command into whatever document is
// live next, which is the mount that replaced this one.
started = null
host.innerHTML = ''
host.classList.remove(HOST_CLASS)
},
ready
}
}
/** Undoes a document that did start: its listener, its module sequence and its terminals. */
function teardownStartedDocument({ modules, onWindowResize }: StartedDocument) {
window.removeEventListener('resize', onWindowResize)
modules.stopPageDocumentModules()
const { scope } = modules
// Both terminals, because a swap that never committed leaves two. `beginTerminalSurfaceSwap`
// opens a hidden replacement and `commitTerminalSurfaceSwap` disposes the one it replaced; an
// unmount between the two leaves the committed terminal live with nothing pointing at it. They
// are the same object whenever no swap is open, so the pair is deduplicated.
for (const terminal of new Set([scope.term, scope.committedTerm])) {
try {
terminal?.dispose()
} catch {}
}
scope.term = null
scope.committedTerm = null
}
/**
* Builds and starts the document, and hands it to `adopt` — or returns having done neither.
*
* The token is read again the instant the import lands, before anything below it runs. Every
* statement after this point writes shared state: the six seams are fields on a module-singleton
* scope, `startPageDocumentModules` resets that scope and installs listeners, and the resize
* listener outlives the host. A mount disposed while its chunk was in flight owns none of it, and
* running the body anyway would plant its elements' listeners into a page a later mount is using
* and reset that mount's scope out from under it. Checking only when this resolves is too late:
* by then the writes have happened and the caller can do nothing but discard the result.
*
* `adopt` rather than a return value for the same reason: what it hands over is what undoes all of
* that, and the caller has to be holding it before this function's turn ends.
*/
async function buildTerminalWebDocument(
host: HTMLElement,
receive: (message: Record<string, unknown>) => void,
token: symbol,
adopt: (built: StartedDocument) => void
): Promise<void> {
const documentModules = await import('./document/page-document-modules')
if (liveDocument !== token) {
return
}
const { scope } = documentModules
// Ruling 19 reaches `window.onerror` too: the WebView's document owns its page and may take
// that handler, but this one is a guest. An `error` listener reports the same failures without
// displacing whatever the page installed, and it hands back its own removal so `stopHostNotify`
// takes it off with everything else.
scope.installErrorReporter = (report) => {
const errorListener = (event: ErrorEvent) => {
report(event.message, event.filename, event.lineno, event.colno, event.error)
}
window.addEventListener('error', errorListener)
return () => window.removeEventListener('error', errorListener)
}
// Ruling 19 again, for colour: inside the WebView the terminal's theme is the page's own
// background and the document paints `html` and `body` with it. Here those belong to the
// application, and a repaint would outlive the terminal, so the host element takes it instead —
// it is the element the grid sits on, which is what the paint was for.
scope.paintDocumentBackground = (background) => {
host.style.background = background
}
scope.postToHost = receive
scope.createTerminal = (options) =>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm's own Terminal is the engine the document was written against; its options are declared optional where the document's shape declares them present, which is the only difference.
new Terminal(options) as unknown as ReturnType<typeof scope.createTerminal>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the addon's public surface is `dispose`, which the document's shape names; the two optional members it also reads are absent here and guarded there.
scope.createUnicode11Addon = () => new Unicode11Addon() as unknown as TerminalDocumentWebglAddon
scope.createWebglAddon = () =>
createPageWebglAddon((reason) =>
receive({
type: 'log',
tag: '[fit]webgl-unavailable',
payload: { renderer: 'dom', message: reason }
})
)
// Now the document itself, with every seam already in place.
documentModules.startPageDocumentModules()
// `message-bridge` is not imported (ruling 19), so its one non-bridge duty is re-armed here:
// a viewport change has to re-fit, or opening the keyboard leaves the terminal at the old scale.
const onWindowResize = () => {
documentModules.applyFitScale('window-resize')
documentModules.adjustRowsForViewport()
documentModules.repositionOverlay()
documentModules.clampPan()
documentModules.updateTransform()
}
window.addEventListener('resize', onWindowResize)
adopt({ modules: documentModules, onWindowResize })
}
@@ -0,0 +1,324 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mountTerminalWebDocument } from './terminal-web-document-mount'
/**
* One live document per page, and the undo that frees the page for the next one.
*
* `document-scope` is a module singleton: every module in `document/` reads that one object, so
* two mounts at once would not be two terminals but two drivers of the same fields and the same
* elements. The component cannot reach that state — it mounts and disposes in one effect — which
* is why the refusal is named here rather than left to surface as two terminals overwriting each
* other's surface. The second half is the one the page actually uses: dispose has to give the
* page back, or a remount and the error overlay's Reload would both be refused.
*/
describe('the page terminal document', () => {
beforeEach(() => {
document.body.innerHTML = ''
document.head.innerHTML = ''
})
it('refuses a second mount while one is live, and takes it back on dispose', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
const second = document.createElement('div')
document.body.appendChild(second)
const mounted = mountTerminalWebDocument(host, () => {})
await mounted.ready
expect(() => mountTerminalWebDocument(second, () => {})).toThrow(
'the terminal document is already mounted on this page'
)
mounted.dispose()
const remounted = mountTerminalWebDocument(second, () => {})
await remounted.ready
expect(second.querySelector('#terminal-container')).not.toBe(null)
remounted.dispose()
})
it('disposes the terminal a swap left behind, not only the live one', async () => {
// `beginTerminalSurfaceSwap` opens a hidden replacement and hands the committed terminal to
// `commitTerminalSurfaceSwap`, which disposes it. An unmount between the two is the case this
// covers: the committed terminal is nobody's, and a dispose that reached only `scope.term`
// would leave it holding its renderer, its observers and its buffers for the life of the tab.
const host = document.createElement('div')
document.body.appendChild(host)
const mounted = mountTerminalWebDocument(host, () => {})
await mounted.ready
const { scope } = await import('./document/page-document-modules')
const disposed: string[] = []
const fake = (name: string) => ({ dispose: () => disposed.push(name) })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member this case reaches, and the two doubles carry it.
scope.committedTerm = fake('committed') as unknown as typeof scope.committedTerm
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above; the mount's dispose calls nothing else on either.
scope.term = fake('live') as unknown as typeof scope.term
mounted.dispose()
expect(disposed.sort()).toEqual(['committed', 'live'])
expect(scope.term).toBe(null)
expect(scope.committedTerm).toBe(null)
})
it('disposes one terminal once when no swap is open', async () => {
// The other half: with no swap in flight the two fields are the same object, and disposing it
// twice is what the deduplication exists to stop.
const host = document.createElement('div')
document.body.appendChild(host)
const mounted = mountTerminalWebDocument(host, () => {})
await mounted.ready
const { scope } = await import('./document/page-document-modules')
let disposals = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member the mount's dispose reaches.
const only = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
scope.term = only
scope.committedTerm = only
mounted.dispose()
expect(disposals).toBe(1)
})
it('tears down once, however many times the handle is disposed', async () => {
// A handle outlives what it built: the component keeps one in a ref, and React may run a
// cleanup twice. Everything dispose touches is shared, so what a second run would reach is
// whatever owns the scope by then — stood in for here by a terminal put back after the first
// dispose, which is what the next mount does.
const host = document.createElement('div')
document.body.appendChild(host)
const mounted = mountTerminalWebDocument(host, () => {})
await mounted.ready
const { scope } = await import('./document/page-document-modules')
mounted.dispose()
let disposals = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member the mount's dispose reaches on a terminal.
scope.term = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
mounted.dispose()
mounted.dispose()
expect(disposals).toBe(0)
expect(scope.term).not.toBe(null)
})
it('does nothing when a stale handle is disposed after another document mounted', async () => {
// The case the idempotence check alone would miss. The first handle is spent, a second
// document is up, and the first handle's dispose arrives late — from a ref, from a cleanup
// React deferred. Comparing a token rather than the host or its class is what makes this
// answerable: the two mounts can be handed the same element.
const first = document.createElement('div')
const second = document.createElement('div')
document.body.append(first, second)
const stale = mountTerminalWebDocument(first, () => {})
await stale.ready
stale.dispose()
const live = mountTerminalWebDocument(second, () => {})
await live.ready
const { scope } = await import('./document/page-document-modules')
let disposals = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above; the live document's terminal is only ever disposed here.
scope.term = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
stale.dispose()
expect(disposals).toBe(0)
expect(second.querySelector('#terminal-container')).not.toBe(null)
expect(scope.term).not.toBe(null)
// And the page is still taken, so the live document is still the one that owns it.
expect(() => mountTerminalWebDocument(first, () => {})).toThrow(
'the terminal document is already mounted on this page'
)
live.dispose()
})
it('tells two mounts of the same element apart, which a host comparison cannot', async () => {
// Why the claim is a token and not the host. React reuses elements, so the page can hand the
// second mount the very element the first one used — that is the ordinary remount, not a
// corner. A dispose that asked "is this my host?" would answer yes for both handles, and the
// stale one would tear down the live document while leaving the page claimed.
const host = document.createElement('div')
document.body.appendChild(host)
const stale = mountTerminalWebDocument(host, () => {})
await stale.ready
stale.dispose()
const live = mountTerminalWebDocument(host, () => {})
await live.ready
const { scope } = await import('./document/page-document-modules')
let disposals = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispose is the only member the mount's dispose reaches on a terminal.
scope.term = { dispose: () => (disposals += 1) } as unknown as typeof scope.term
stale.dispose()
expect(disposals).toBe(0)
expect(host.querySelector('#terminal-container')).not.toBe(null)
expect(host.classList.contains('orca-terminal-document-host')).toBe(true)
expect(() => mountTerminalWebDocument(host, () => {})).toThrow(
'the terminal document is already mounted on this page'
)
live.dispose()
})
it('touches nothing when it is disposed before its chunk lands', async () => {
// The window the synchronous handle opened. `dispose` can now run while the import is still
// unresolved, so the build resumes on a page it no longer owns — and everything after its
// await writes shared state: the six seams are fields on one module scope, and
// `startPageDocumentModules` resets that scope and installs the document's listeners. A mount
// that checked only when it resolved would have done all of that first and then discarded the
// result, leaving the listeners behind and a later mount's scope reset out from under it.
const host = document.createElement('div')
document.body.appendChild(host)
const { scope } = await import('./document/page-document-modules')
const seamsBefore = {
postToHost: scope.postToHost,
installErrorReporter: scope.installErrorReporter,
paintDocumentBackground: scope.paintDocumentBackground,
createTerminal: scope.createTerminal,
createUnicode11Addon: scope.createUnicode11Addon,
createWebglAddon: scope.createWebglAddon
}
const generationBefore = scope.terminalGeneration
const mounted = mountTerminalWebDocument(host, () => {})
mounted.dispose()
// Armed after the dispose, so anything they catch is the resuming build and nothing else.
const listeners = vi.spyOn(EventTarget.prototype, 'addEventListener')
const timers = vi.spyOn(globalThis, 'setTimeout')
const frames = vi.spyOn(globalThis, 'requestAnimationFrame')
try {
// Resolves: the caller asked for the terminal and then asked for it to go away, so the
// chunk landing afterwards is not a failure to report to the error overlay.
await expect(mounted.ready).resolves.toBeUndefined()
} finally {
listeners.mockRestore()
timers.mockRestore()
frames.mockRestore()
}
expect(listeners).not.toHaveBeenCalled()
expect(timers).not.toHaveBeenCalled()
expect(frames).not.toHaveBeenCalled()
expect({
postToHost: scope.postToHost,
installErrorReporter: scope.installErrorReporter,
paintDocumentBackground: scope.paintDocumentBackground,
createTerminal: scope.createTerminal,
createUnicode11Addon: scope.createUnicode11Addon,
createWebglAddon: scope.createWebglAddon
}).toEqual(seamsBefore)
// `startPageDocumentModules` resets the scope, which carries this forward by one. Unchanged
// is the start sequence never having run.
expect(scope.terminalGeneration).toBe(generationBefore)
// And the page is free, which is what the overlay's Reload needs.
const remounted = mountTerminalWebDocument(host, () => {})
await remounted.ready
expect(host.querySelector('#terminal-container')).not.toBe(null)
remounted.dispose()
})
it('tears down a document that started, however late the dispose is', async () => {
// The start sequence and the handle on what undoes it have to land in one turn. They did not:
// the build started the document, installed its listeners and returned, and the assignment
// that recorded it ran a microtask later — so a dispose in between found nothing started,
// skipped the teardown and handed the page back with the document still running on it. The
// dispose here is queued behind the document module import the build awaits, which puts it in
// that window rather than before or after it.
const host = document.createElement('div')
document.body.appendChild(host)
const listeners = new Set<unknown>()
let adds = 0
const realAdd = window.addEventListener.bind(window)
const realRemove = window.removeEventListener.bind(window)
// Parameters taken from the bound original, so the wrapper carries the real signature rather
// than three implicit `any`s the tests typecheck refuses.
window.addEventListener = (...added: Parameters<typeof realAdd>) => {
if (added[0] === 'resize') {
adds += 1
listeners.add(added[1])
}
realAdd(...added)
}
window.removeEventListener = (...removed: Parameters<typeof realRemove>) => {
if (removed[0] === 'resize') {
listeners.delete(removed[1])
}
realRemove(...removed)
}
const mounted = mountTerminalWebDocument(host, () => {})
try {
await import('./document/page-document-modules')
mounted.dispose()
await mounted.ready
} finally {
window.addEventListener = realAdd
window.removeEventListener = realRemove
}
// The precondition: the document did start, so there was something to tear down. A build that
// returned right after its ownership check would add nothing and satisfy the emptiness below
// for the one reason this case exists to refuse. Counted on the way in rather than read off
// the host afterwards — dispose empties the host on every path, so that told us nothing.
expect(adds, 'the document started and added its resize listener').toBe(1)
expect(listeners.size, 'the resize listener the started document added').toBe(0)
const { scope } = await import('./document/page-document-modules')
expect(scope.term).toBe(null)
// And the page is free, which a handle that lost track of what it started would not have left.
const remounted = mountTerminalWebDocument(host, () => {})
await remounted.ready
remounted.dispose()
})
it('routes nothing into the document that replaced it', async () => {
// `send` reads what the mount adopted, and what it adopted names the page's one set of
// document modules. A handle that kept them after its dispose would hand a host command to
// whichever document is live next: same modules, same scope, a terminal that is not its own.
// `ping` is the cheapest way to see it, because the document answers it by posting through the
// scope's `postToHost` seam — which by then belongs to the mount that replaced this one.
const host = document.createElement('div')
document.body.appendChild(host)
const stale = mountTerminalWebDocument(host, () => {})
await stale.ready
stale.dispose()
const posts: unknown[] = []
const live = mountTerminalWebDocument(host, (message) => posts.push(message.type))
await live.ready
stale.send({ id: 4242, type: 'ping' })
expect(posts).toEqual([])
// The precondition: the live document does answer a ping, so the silence above is the stale
// handle declining to speak rather than the command doing nothing.
live.send({ id: 4243, type: 'ping' })
expect(posts).toEqual(['pong'])
live.dispose()
})
it('gives the page back when the mount itself fails, so Reload can try again', async () => {
// The overlay's Reload path. A mount that threw holds nothing, and a flag left set would
// refuse every later attempt — the document's chunk failing to load is exactly that case.
const detached = document.createElement('div')
Object.defineProperty(detached, 'innerHTML', {
set() {
throw new Error('orca-mount-failed')
},
get() {
return ''
}
})
expect(() => mountTerminalWebDocument(detached, () => {})).toThrow('orca-mount-failed')
const host = document.createElement('div')
document.body.appendChild(host)
const mounted = mountTerminalWebDocument(host, () => {})
await mounted.ready
expect(host.querySelector('#terminal-container')).not.toBe(null)
mounted.dispose()
})
})
@@ -0,0 +1,85 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* Nothing above the terminal component knows which of the two it has.
*
* The page's `TerminalWebView.web.tsx` is resolved by the bundler, not chosen by a caller, so the
* whole substitution rests on every consumer holding only `terminal-webview-contract` — the props
* and the handle — and on the one that renders the component naming it without an extension. A
* consumer that reached into `TerminalWebView.tsx` for a type, or imported `terminal-webview-html`
* for the document string, would work natively and break on the page in a way no native test sees.
*
* Scanned rather than listed, so a new consumer joins the rule by existing.
*/
const terminalDir = import.meta.dirname
const sessionDir = join(terminalDir, '..', 'session')
/** The modules that may reach into the component's own file or the document's HTML. */
const ALLOWED_INSIDE_TERMINAL = new Set([
'TerminalWebView.tsx',
'TerminalWebView.web.tsx',
'terminal-web-document-mount.ts',
'terminal-webview-html.ts',
'terminal-webview-html.web.ts',
// Test scaffolding that runs the WebView's own document text; it is not shipped in either build.
'terminal-webview-mouse-test-harness.ts'
])
const FORBIDDEN_ABOVE_THE_CONTRACT = [
/from '(\.\.\/terminal|\.)\/TerminalWebView\.(web\.)?tsx?'/,
/from '(\.\.\/terminal|\.)\/terminal-webview-html'/,
/from '(\.\.\/terminal|\.)\/terminal-webview-engine(-css)?\.generated'/,
/from '(\.\.\/terminal|\.)\/document\//
]
function productModules(directory: string): string[] {
return readdirSync(directory)
.filter((name) => /\.tsx?$/.test(name))
.filter((name) => !name.includes('.test') && !name.includes('.test-support'))
.sort()
}
function offenders(directory: string, skip: (name: string) => boolean): string[] {
const found: string[] = []
for (const name of productModules(directory)) {
if (skip(name)) {
continue
}
const source = readFileSync(join(directory, name), 'utf8')
for (const pattern of FORBIDDEN_ABOVE_THE_CONTRACT) {
if (pattern.test(source)) {
found.push(`${name}: ${pattern.source}`)
}
}
}
return found
}
describe('the terminal component contract', () => {
it('is all that src/session imports of the terminal', () => {
expect(offenders(sessionDir, () => false)).toEqual([])
// The precondition: a scan that read no session module would report nothing either.
const rendering = readFileSync(join(sessionDir, 'TerminalPaneView.tsx'), 'utf8')
expect(rendering).toContain("from '../terminal/TerminalWebView'")
expect(rendering).toContain("from '../terminal/terminal-webview-contract'")
})
it('is all that the terminal directory itself imports, outside the component and its document', () => {
expect(offenders(terminalDir, (name) => ALLOWED_INSIDE_TERMINAL.has(name))).toEqual([])
expect(productModules(terminalDir).length).toBeGreaterThan(40)
})
it('would report a consumer that named the component file', () => {
// The scan tested on the text it is meant to refuse, so an empty offender list above is a
// measurement rather than a regex that matches nothing.
const planted = "import { TerminalWebView } from '../terminal/TerminalWebView.tsx'\n"
expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(planted))).toBe(true)
const html = "import { XTERM_HTML } from '../terminal/terminal-webview-html'\n"
expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(html))).toBe(true)
const extensionless = "import { TerminalWebView } from '../terminal/TerminalWebView'\n"
expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(extensionless))).toBe(false)
})
})
@@ -1,7 +1,8 @@
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { documentScopePreamble } from './document/generated-document-region.test-support'
import { XTERM_HTML } from './terminal-webview-html'
@@ -75,6 +76,7 @@ scope.term = term;
scope.terminalGeneration = terminalGeneration;
scope.terminalThemeInput = terminalThemeInput;
${terminalHtmlSource.slice(recoveryStart, recoveryEnd)}
startWebglRecovery();
attachWebglAddon(true);`).runInNewContext(context)
return {
addons,
@@ -169,7 +171,8 @@ describe('terminal WebView bundled engine', () => {
// old surface visible meanwhile), so the fatal default and the init-catch must
// key off `everReady` — otherwise a transient reflow error blanks a live
// terminal behind the fatal overlay. The latch stays set for the document.
expect(terminalHtmlSource).toContain('scope.everReady = false;')
// Ruling 21: the latch's initial value is in the scope factory, not in a parse-time write.
expect(terminalHtmlSource).toContain('everReady: false,')
expect(terminalHtmlSource).toContain('scope.everReady = true;')
expect(terminalHtmlSource).toContain('fatal === void 0 ? !scope.everReady : !!fatal')
expect(terminalHtmlSource).toContain('msg.type === "init" && !scope.everReady')
@@ -3,6 +3,14 @@ import { TERMINAL_HTML_DOCUMENT_CLOSE } from './terminal-webview-html/document-c
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
// Re-exported so the page's `.web.ts` sibling can answer the same names without the document
// string: whatever imports this gets markup and style on both platforms. The page takes the
// element half only — the document-level rules are this document's alone.
export { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html/document-markup'
export {
TERMINAL_DOCUMENT_ELEMENT_STYLE,
TERMINAL_DOCUMENT_STYLE
} from './terminal-webview-html/document-style'
// Why: the script the WebView runs is generated from `src/terminal/document/`, the same modules the
// web page imports, so there is one source for both. The shell and the close are still text: they
@@ -0,0 +1,20 @@
/**
* What the page needs from the terminal's HTML, which is the markup and the stylesheet and
* nothing else.
*
* The native file composes a whole document: the shell, the 612 KiB engine string and the
* generated script. On the page none of those three can be used. The shell's CSP is
* `script-src 'self'` with `frame-src 'none'`, so there is no nested document to load and no
* inline script to run; the engine arrives as an import instead, and the script's modules are
* imported directly. Exporting the document string here would put all of it in the page's closure
* to be dropped — `mobile-web-terminal-engine-closure.test.mjs` is the fence that says it is not.
*
* The stylesheet is the element half only. The document-level rules — `*`, `html`, `body` — are
* the WebView's alone: on the page they would restyle the whole application and go on doing it
* after the terminal is gone. `scopeStyleToHost` is what holds the rest under the host element.
*
* `MOBILE_TERMINAL_CARET_OPTIONS` is re-exported because both hosts build the same caret.
*/
export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
export { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html/document-markup'
export { TERMINAL_DOCUMENT_ELEMENT_STYLE } from './terminal-webview-html/document-style'
@@ -0,0 +1,19 @@
/**
* The elements the document's modules reach for by id: the surface xterm is opened on, the
* scroll indicator, and the selection overlay with its two handles and its menu pill.
*
* They are read as the modules are parsed, so whatever hosts the document — the WebView's body
* or the page's own container — has to have planted this first.
*/
export const TERMINAL_DOCUMENT_MARKUP = `<div id="terminal-container">
<div id="terminal-surface"></div>
</div>
<div id="scroll-indicator"><div id="scroll-thumb"></div></div>
<div id="selection-overlay">
<div id="sel-handle-start" class="sel-handle start"></div>
<div id="sel-handle-end" class="sel-handle end"></div>
<div id="sel-menu">
<button id="sel-menu-copy">Copy</button>
<button id="sel-menu-all">Select All</button>
</div>
</div>`
@@ -1,5 +1,7 @@
import { colors } from '../../theme/mobile-theme'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
import { TERMINAL_DOCUMENT_MARKUP } from './document-markup'
import { TERMINAL_DOCUMENT_STYLE } from './document-style'
import { XTERM_ENGINE_CSS } from '../terminal-webview-engine-css.generated'
import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
export const TERMINAL_HTML_DOCUMENT_SHELL = `<!DOCTYPE html>
<html>
@@ -16,149 +18,11 @@ window.onerror = function(msg) {
</script>
<style>${XTERM_ENGINE_CSS}</style>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: ${colors.terminalBg};
overflow: hidden;
width: 100%;
height: 100%;
}
#terminal-container {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
}
#terminal-surface {
transform-origin: top left;
display: inline-block;
}
.xterm { -webkit-user-select: none; user-select: none; font-variant-emoji: text; }
.xterm .xterm-viewport {
overflow-y: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none;
}
.xterm .xterm-viewport::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
.xterm .xterm-scrollable-element > .xterm-scrollbar,
.xterm .xterm-scrollbar {
display: none !important;
width: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
#scroll-indicator {
position: fixed;
top: 4px;
right: 3px;
bottom: 4px;
width: 3px;
pointer-events: none;
opacity: 0;
transition: opacity 120ms linear;
z-index: 7;
}
#scroll-indicator.visible { opacity: 0.72; }
#scroll-thumb {
position: absolute;
top: 0;
right: 0;
width: 3px;
min-height: 24px;
border-radius: 999px;
background: ${colors.textSecondary};
will-change: transform, height;
}
/* Why: selection overlay sits in unscaled viewport coords, above the
transformed surface, so handle hit areas and Copy menu positions
don't depend on getTotalScale() for their on-screen size. */
#selection-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 10;
display: none;
}
#selection-overlay.active { display: block; }
.sel-handle {
position: absolute;
width: 44px; height: 44px;
margin-left: -22px; margin-top: -22px;
pointer-events: auto;
background: transparent;
}
.sel-handle::before {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 14px; height: 14px;
background: #7aa2f7;
border-radius: 50%;
border: 2px solid #c0caf5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.sel-handle.start::before { top: 8px; }
.sel-handle.start::after {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
.sel-handle.end::before { top: 22px; }
.sel-handle.end::after {
content: '';
position: absolute;
left: 50%; top: 6px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
#sel-menu {
position: absolute;
pointer-events: auto;
background: #2a2f4a;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
display: flex;
overflow: hidden;
transform: translateY(-100%);
margin-top: -12px;
user-select: none;
-webkit-user-select: none;
}
#sel-menu button {
background: transparent;
border: none;
color: #c0caf5;
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 10px 16px;
cursor: pointer;
}
#sel-menu button:active { background: #414868; }
#sel-menu button + button { border-left: 1px solid #414868; }
${TERMINAL_DOCUMENT_STYLE}
</style>
</head>
<body>
<div id="terminal-container">
<div id="terminal-surface"></div>
</div>
<div id="scroll-indicator"><div id="scroll-thumb"></div></div>
<div id="selection-overlay">
<div id="sel-handle-start" class="sel-handle start"></div>
<div id="sel-handle-end" class="sel-handle end"></div>
<div id="sel-menu">
<button id="sel-menu-copy">Copy</button>
<button id="sel-menu-all">Select All</button>
</div>
</div>
${TERMINAL_DOCUMENT_MARKUP}
<script>${XTERM_ENGINE_JS}</script>
<script>
`
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { XTERM_ENGINE_CSS } from '../terminal-webview-engine-css.generated'
import {
TERMINAL_DOCUMENT_ELEMENT_STYLE,
TERMINAL_DOCUMENT_ROOT_STYLE,
TERMINAL_DOCUMENT_STYLE
} from './document-style'
import {
documentLevelRules,
isDocumentLevelSelector,
scopeStyleToHost
} from './document-style-scoping'
/**
* What the page is allowed to inject, and what the split leaves the WebView.
*
* The document's sheet says `*`, `html` and `body` because inside a WebView it owns the page.
* Appended to the head of a React Native Web application it owns nothing and restyles everything,
* including after the terminal is gone. So the page takes the element half and holds every
* selector under its host; these are the two halves of that claim, measured rather than asserted
* in prose.
*/
const PREFIX = '.orca-terminal-document-host'
function selectorsOf(css: string): string[] {
return [...css.matchAll(/(?:^|\})\s*([^{}]+)\{/g)].flatMap((match) =>
match[1]!.split(',').map((one) => one.trim())
)
}
describe('the terminal document stylesheet', () => {
it('splits into two halves that still compose the sheet the WebView carries', () => {
// The native document must not move for the split, which is what the byte golden says; this
// is the same claim one level down, where a reordering would be visible as text.
expect(TERMINAL_DOCUMENT_STYLE).toBe(
`${TERMINAL_DOCUMENT_ROOT_STYLE}\n${TERMINAL_DOCUMENT_ELEMENT_STYLE}`
)
})
it('keeps every document-level rule out of the half the page takes', () => {
expect(documentLevelRules(TERMINAL_DOCUMENT_ROOT_STYLE)).toEqual(['*', 'html, body'])
expect(documentLevelRules(TERMINAL_DOCUMENT_ELEMENT_STYLE)).toEqual([])
// The precondition for the empty list: the reader does find them when they are there.
expect(isDocumentLevelSelector('body')).toBe(true)
expect(isDocumentLevelSelector('html, body')).toBe(true)
expect(isDocumentLevelSelector('*')).toBe(true)
expect(isDocumentLevelSelector('#terminal-container')).toBe(false)
expect(isDocumentLevelSelector('.xterm .xterm-viewport')).toBe(false)
})
it('holds every selector of both injected sheets under the host', () => {
for (const sheet of [TERMINAL_DOCUMENT_ELEMENT_STYLE, XTERM_ENGINE_CSS]) {
const scoped = scopeStyleToHost(sheet, PREFIX)
const selectors = selectorsOf(scoped)
expect(selectors.length).toBeGreaterThan(0)
expect(selectors.filter((one) => !one.startsWith(`${PREFIX} `))).toEqual([])
}
})
it('drops a document-level rule rather than prefixing it', () => {
// `.host *` is not what `*` meant, and a page has no use for either reading.
const scoped = scopeStyleToHost(TERMINAL_DOCUMENT_ROOT_STYLE, PREFIX)
expect(scoped.trim()).toBe('')
})
it('refuses a sheet whose shape it cannot rewrite', () => {
// The rewrite is textual because the input is flat. A sheet that grew an at-rule would have
// its inner selectors passed through unscoped, so it throws instead.
expect(() =>
scopeStyleToHost('@media (min-width: 1px) { .a { color: red; } }', PREFIX)
).toThrow('at-rules cannot be scoped')
expect(() => scopeStyleToHost('.a { color: red;', PREFIX)).toThrow('never closes')
})
})
@@ -0,0 +1,92 @@
/**
* The terminal's own rules, rewritten to reach only what the host element contains.
*
* Inside the WebView the document owns its page, so its stylesheet says `*`, `html` and `body`
* and means it. On the page the document is a guest: the same sheet, appended to the head of a
* React Native Web application, restyles every screen the shell can show and keeps doing it after
* the terminal is gone. Ruling 19's shape applies to CSS as it does to `window.onerror` — the
* page mount may style only what it owns — so the document-level rules are dropped and every
* remaining selector is held under the host's own class.
*
* A prefix rather than a shadow root: the document reads its elements by id through
* `document.getElementById`, which does not cross a shadow boundary, and xterm's own sheet is
* written against `.xterm` in the same document. Both would need a different program.
*
* The rewrite is textual because the input is: two flat stylesheets this repository writes or
* generates, with no at-rules and no nesting. Anything else throws rather than passing a rule
* through unscoped, and `document-style-scoping.test.ts` holds that.
*/
/** A rule's selector list and its declaration block, as the source text writes them. */
type StyleRule = { selectors: string; declarations: string }
function stripComments(text: string): string {
return text.replaceAll(/\/\*[\s\S]*?\*\//g, '')
}
/** The sheet as a flat list of rules; comments and whitespace between them are dropped. */
function parseStyleRules(css: string): StyleRule[] {
const rules: StyleRule[] = []
let at = 0
while (at < css.length) {
const open = css.indexOf('{', at)
if (open === -1) {
break
}
const close = css.indexOf('}', open)
if (close === -1) {
throw new Error('the stylesheet has a rule that never closes')
}
const selectors = stripComments(css.slice(at, open)).trim()
if (selectors.includes('@')) {
throw new Error(`at-rules cannot be scoped to a host: ${selectors}`)
}
if (selectors.length === 0) {
throw new Error('the stylesheet has a declaration block with no selector')
}
rules.push({ selectors, declarations: css.slice(open, close + 1) })
at = close + 1
}
return rules
}
/** The element a selector starts from, or the empty string when it starts from a class or an id. */
function leadingElement(selector: string): string {
return selector.trim().split(/[\s>+~:.[#]/)[0] ?? ''
}
/**
* Whether a selector addresses the document itself rather than something inside it.
*
* These are the rules a page must not carry: one that kept them would set the application's own
* background and overflow, and every element's box model, for as long as the sheet is in the head.
*/
export function isDocumentLevelSelector(selectors: string): boolean {
return selectors.split(',').some((one) => ['*', 'html', 'body'].includes(leadingElement(one)))
}
/** The rules a page may not inject, as one line each. Exported so a test can name them. */
export function documentLevelRules(css: string): string[] {
return parseStyleRules(css)
.filter((rule) => isDocumentLevelSelector(rule.selectors))
.map((rule) => rule.selectors)
}
/**
* The same stylesheet with every selector held under `prefix`.
*
* A rule that addresses the document itself is dropped rather than prefixed: `.host *` is not
* what `*` meant, and the page has no use for either reading.
*/
export function scopeStyleToHost(css: string, prefix: string): string {
return parseStyleRules(css)
.filter((rule) => !isDocumentLevelSelector(rule.selectors))
.map((rule) => {
const scoped = rule.selectors
.split(',')
.map((one) => `${prefix} ${one.trim()}`)
.join(',\n')
return `${scoped} ${rule.declarations}`
})
.join('\n')
}
@@ -0,0 +1,158 @@
import { colors } from '../../theme/mobile-theme'
/**
* The rules that style the document itself, which only the WebView's document may carry.
*
* Inside the WebView this is the terminal's own page and these say so. On the page the document
* is a guest in a React Native Web application, and the same three selectors would set that
* application's background, its overflow and every element's box model — and keep doing it after
* the terminal is gone. So the page never injects them; `document-style-scoping.ts` is what
* separates them from the rules below, and it recognises them by their selectors rather than by
* this split, so a fourth one added here is still caught there.
*/
export const TERMINAL_DOCUMENT_ROOT_STYLE = ` * { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: ${colors.terminalBg};
overflow: hidden;
width: 100%;
height: 100%;
}`
/**
* The terminal's own elements, beside xterm's sheet.
*
* Split out of the document shell because the page needs exactly this and must not reach the
* engine string the shell also splices in. The rules are addressed at the ids and classes
* `document-markup.ts` declares, which is the other half of the same pair; the page holds every
* one of them under its host element rather than letting them loose in the application.
*/
export const TERMINAL_DOCUMENT_ELEMENT_STYLE = ` #terminal-container {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
}
#terminal-surface {
transform-origin: top left;
display: inline-block;
}
.xterm { -webkit-user-select: none; user-select: none; font-variant-emoji: text; }
.xterm .xterm-viewport {
overflow-y: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none;
}
.xterm .xterm-viewport::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
.xterm .xterm-scrollable-element > .xterm-scrollbar,
.xterm .xterm-scrollbar {
display: none !important;
width: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
#scroll-indicator {
position: fixed;
top: 4px;
right: 3px;
bottom: 4px;
width: 3px;
pointer-events: none;
opacity: 0;
transition: opacity 120ms linear;
z-index: 7;
}
#scroll-indicator.visible { opacity: 0.72; }
#scroll-thumb {
position: absolute;
top: 0;
right: 0;
width: 3px;
min-height: 24px;
border-radius: 999px;
background: ${colors.textSecondary};
will-change: transform, height;
}
/* Why: selection overlay sits in unscaled viewport coords, above the
transformed surface, so handle hit areas and Copy menu positions
don't depend on getTotalScale() for their on-screen size. */
#selection-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 10;
display: none;
}
#selection-overlay.active { display: block; }
.sel-handle {
position: absolute;
width: 44px; height: 44px;
margin-left: -22px; margin-top: -22px;
pointer-events: auto;
background: transparent;
}
.sel-handle::before {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 14px; height: 14px;
background: #7aa2f7;
border-radius: 50%;
border: 2px solid #c0caf5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.sel-handle.start::before { top: 8px; }
.sel-handle.start::after {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
.sel-handle.end::before { top: 22px; }
.sel-handle.end::after {
content: '';
position: absolute;
left: 50%; top: 6px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
#sel-menu {
position: absolute;
pointer-events: auto;
background: #2a2f4a;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
display: flex;
overflow: hidden;
transform: translateY(-100%);
margin-top: -12px;
user-select: none;
-webkit-user-select: none;
}
#sel-menu button {
background: transparent;
border: none;
color: #c0caf5;
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 10px 16px;
cursor: pointer;
}
#sel-menu button:active { background: #414868; }
#sel-menu button + button { border-left: 1px solid #414868; }`
/**
* Both halves, in the order the WebView's `<head>` has always carried them.
*
* The concatenation is what the document shell splices in, so the emitted document does not move
* for this split — the byte golden says whether that held.
*/
export const TERMINAL_DOCUMENT_STYLE = `${TERMINAL_DOCUMENT_ROOT_STYLE}
${TERMINAL_DOCUMENT_ELEMENT_STYLE}`
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = 'c84ce5fc7343546427ad875aeebea90e54560579a1d18b3b700076a1c4b4623f'
const EXPECTED_LENGTH = 723480
const EXPECTED_SHA256 = '9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce'
const EXPECTED_LENGTH = 726363
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
@@ -0,0 +1,118 @@
import { useMemo } from 'react'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
/**
* The two promises the terminal handle hands out, and the notifies that settle them.
*
* `awaitReady` waits for the document's `init` rAF chain — `term.open`, renderService population,
* first paint — because a measure that runs synchronously after init finds `term` null or cells
* of size zero. `measureFitDimensions` waits for the document's answer. Both are promises held
* across a message round trip, both have a timeout for the case where the document never answers,
* and both are the same on either host, so they live here rather than in the controller that owns
* the readiness handshake.
*/
const READY_TIMEOUT_MS = 3000
const MEASURE_TIMEOUT_MS = 2000
/** Below these the fit is not a terminal anyone can read, and the caller disables fit-to-phone. */
const MIN_FIT_COLS = 20
const MIN_FIT_ROWS = 8
export type TerminalFitDimensions = { cols: number; rows: number }
export function createTerminalWebViewReadyPromises() {
let readyPromise: Promise<void> | null = null
let readyResolve: (() => void) | null = null
let measureResolve: ((result: TerminalFitDimensions | null) => void) | null = null
/**
* Arms a fresh ready promise, resolving any prior one first.
*
* Why: an awaiter from the previous generation would otherwise sit on the timeout below — each
* leaked timer and closure pinned an awaiting measure caller for the full 3s under rapid
* re-init (orientation change, multiple resubscribes), delaying cold-start fit chains.
*/
function armReady() {
const priorResolve = readyResolve
readyResolve = null
readyPromise = null
priorResolve?.()
readyPromise = new Promise<void>((resolve) => {
readyResolve = resolve
})
}
function resolveReady() {
const resolve = readyResolve
readyResolve = null
readyPromise = null
resolve?.()
}
async function awaitReady(): Promise<void> {
const pending = readyPromise
if (!pending) {
return
}
await new Promise<void>((resolve) => {
let settled = false
const timeout = setTimeout(() => {
settled = true
resolve()
}, READY_TIMEOUT_MS)
void pending.finally(() => {
if (!settled) {
clearTimeout(timeout)
settled = true
resolve()
}
})
})
}
function measure(
send: (command: TerminalWebViewCommand) => void,
containerHeight?: number
): Promise<TerminalFitDimensions | null> {
return new Promise((resolve) => {
measureResolve?.(null)
let timeout: ReturnType<typeof setTimeout> | null = null
const finish = (result: TerminalFitDimensions | null) => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (measureResolve === finish) {
measureResolve = null
}
resolve(result)
}
measureResolve = finish
send({ type: 'measure', containerHeight })
// Why: if the document doesn't respond (e.g., xterm failed to load), resolve null so the
// caller can disable Fit to Phone rather than hanging indefinitely.
timeout = setTimeout(() => {
if (measureResolve === finish) {
finish(null)
}
}, MEASURE_TIMEOUT_MS)
})
}
function resolveMeasure(msg: Record<string, unknown>) {
const resolve = measureResolve
measureResolve = null
if (!resolve) {
return
}
const cols = typeof msg.cols === 'number' ? msg.cols : null
const rows = typeof msg.rows === 'number' ? msg.rows : null
resolve(cols && rows && cols >= MIN_FIT_COLS && rows >= MIN_FIT_ROWS ? { cols, rows } : null)
}
return { armReady, awaitReady, measure, resolveMeasure, resolveReady }
}
export function useTerminalWebViewReadyPromises() {
return useMemo(() => createTerminalWebViewReadyPromises(), [])
}
@@ -9,7 +9,11 @@ import { XTERM_HTML } from './terminal-webview-html'
const reflowSource = await generatedDocumentModule('reflow')
// Use the assembled document so the test covers what the WebView actually runs.
const htmlSource = XTERM_HTML
const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
// The handle is built by the controller both components share, which is where the wiring is read.
const handleSource = readFileSync(
new URL('./use-terminal-webview-controller.ts', import.meta.url),
'utf8'
)
function reflowFnBody(): string {
const start = reflowSource.indexOf('function reflow(cols, rows) {')
@@ -49,7 +53,8 @@ describe('terminal WebView reflow', () => {
})
it('does not locally resize hidden WebViews to a one-column grid', () => {
expect(htmlSource).toContain('scope.MIN_FIT_COLS = 20;')
// Ruling 21: the floor's value is in the scope factory, not in a parse-time write.
expect(htmlSource).toContain('MIN_FIT_COLS: 20,')
expect(htmlSource).toContain('if (cols < scope.MIN_FIT_COLS) {')
expect(htmlSource).toContain('flog("measure-skip-small-width"')
expect(htmlSource).toContain('notify({ type: "measure-result", cols: null, rows: null });')
@@ -77,7 +82,9 @@ describe('terminal WebView reflow', () => {
// between them; if its IIFE-time code threw, the listener below would
// never bind and reflow messages would silently no-op.
const reflowAt = XTERM_HTML.indexOf('function reflow(cols, rows) {')
const dispatchAt = XTERM_HTML.indexOf('const dispatch = {\n mode: "idle"')
// Ruling 21 moved the dispatcher's latch onto the scope, so the dispatcher is located by
// its own first handler rather than by the object it used to declare.
const dispatchAt = XTERM_HTML.indexOf('function onDocumentTouchStart(e) {')
const listenerAt = XTERM_HTML.indexOf('window.addEventListener("message"')
expect(reflowAt).toBeGreaterThanOrEqual(0)
expect(dispatchAt).toBeGreaterThan(reflowAt)
@@ -6,6 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
// generated document. Concatenated so assertions resolve regardless of file.
const source =
readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +
readFileSync(new URL('./use-terminal-webview-controller.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-ready-promises.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') +
XTERM_HTML
const sessionSource = readFileSync(
@@ -31,7 +33,7 @@ describe('TerminalWebView scroll routing', () => {
})
it('maps a downward pull at the bottom to older scrollback rows', () => {
expect(source).toContain('const deltaY = ts.lastY - y;')
expect(source).toContain('const deltaY = scope.touchGesture.lastY - y;')
expect(source).toContain('scope.smoothScrollOffsetY -= deltaY;')
expect(source).toContain(
'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);'
@@ -69,7 +71,9 @@ describe('TerminalWebView scroll routing', () => {
expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))')
)
expect(momentumBlock).toContain('routeScrollLines(lines, ts.lastX, ts.lastY);')
expect(momentumBlock).toContain(
'routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY);'
)
})
it('does not rubber-band normal scroll at scrollback edges', () => {
@@ -88,14 +92,14 @@ describe('TerminalWebView scroll routing', () => {
'{ capture: true, passive: false }'
)
expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))')
expect(touchMoveBlock).toContain('ts.velY = 0;')
expect(touchMoveBlock).toContain('scope.touchGesture.velY = 0;')
const momentumBlock = sliceBetween(
'let momentumStep = function()',
'if (Math.abs(vel) > MIN_VEL)'
)
expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))')
expect(momentumBlock).toContain('ts.momentumId = null;')
expect(momentumBlock).toContain('scope.touchGesture.momentumId = null;')
})
it('coalesces normal touch scroll row commits onto animation frames', () => {
@@ -105,7 +109,9 @@ describe('TerminalWebView scroll routing', () => {
)
expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY;')
expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {')
expect(enqueueBlock).toContain('scope.normalScrollFrameId = requestAnimationFrame(function()')
// Ruling 21: every document frame goes through the scope's registry so dispose can take it
// back; the id is still held here, which is what the reset below cancels.
expect(enqueueBlock).toContain('scope.normalScrollFrameId = scheduleDocumentFrame(function()')
expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)')
const resetBlock = sliceBetween(
@@ -137,13 +143,15 @@ describe('TerminalWebView scroll routing', () => {
})
it('clears WebView await timers when the real response wins', () => {
const measureBlock = sliceBetween('measureFitDimensions(', 'resetZoom()')
// C7.5 moved both promises into `terminal-webview-ready-promises.ts`, which both components
// reach through the controller; the two blocks are the same code in their new home.
const measureBlock = sliceBetween('function measure(', 'function resolveMeasure')
expect(measureBlock).toContain('clearTimeout(timeout)')
expect(measureBlock).toContain('measureResolveRef.current === finish')
expect(measureBlock).toContain('measureResolve === finish')
const readyBlock = sliceBetween('async awaitReady()', '})')
const readyBlock = sliceBetween('async function awaitReady()', 'function measure(')
expect(readyBlock).toContain('clearTimeout(timeout)')
expect(readyBlock).toContain('void p.finally')
expect(readyBlock).toContain('void pending.finally')
})
it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => {
@@ -175,7 +183,7 @@ describe('TerminalWebView scroll routing', () => {
it('smooths velocity samples and uses lower friction for mobile momentum', () => {
expect(source).toContain('function updateTouchVelocity(deltaY, dt)')
expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45')
expect(source).toContain('scope.touchGesture.velY * 0.55 + instantVelocity * 0.45')
expect(source).toContain('const FRICTION = 0.972;')
expect(source).toContain('const MIN_VEL = 0.012;')
})
@@ -215,17 +223,14 @@ describe('TerminalWebView scroll routing', () => {
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return press;')
expect(source).toContain('if (col > 126 || row > 126) {\n return "";')
const touchEndBlock = sliceBetween(
'document.addEventListener(\n "touchend"',
'{ capture: true, passive: true }'
)
const touchEndBlock = sliceBetween('function onDocumentTouchEnd(e)', '\n function ')
expect(touchEndBlock).toContain(
'notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)'
)
const tapHandlerBlock = sliceBetween(
'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)',
'document.addEventListener(\n "touchstart"'
'function onDocumentTouchStart(e)'
)
expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('urlAtViewportPoint')
@@ -26,15 +26,15 @@ const terminalHtmlSource = XTERM_HTML
const terminalWebglRecoverySource = await generatedDocumentModule('webgl-recovery')
function extractStatusDotNormalizer() {
const declarationStart = terminalHtmlSource.indexOf(' scope.CLAUDE_STATUS_DOT =')
const declarationEnd = terminalHtmlSource.indexOf(' scope.PRIVATE_MODE_SCAN_TAIL_LIMIT')
// Ruling 21 put the dot constants in the scope factory, which the preamble already carries, so
// what is sliced here is the normalizer itself and nothing else.
const declarationAt = terminalHtmlSource.indexOf(' const statusDot = String.fromCharCode(9210);')
const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector')
const functionEnd = terminalHtmlSource.indexOf('\n function enqueueWrite', functionStart)
expect(declarationStart).toBeGreaterThanOrEqual(0)
expect(declarationEnd).toBeGreaterThan(declarationStart)
expect(functionStart).toBeGreaterThan(declarationEnd)
expect(declarationAt).toBeGreaterThanOrEqual(0)
expect(functionStart).toBeGreaterThan(declarationAt)
expect(functionEnd).toBeGreaterThan(functionStart)
return `${documentScopePreamble()}${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
return `${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, functionEnd)}`
}
function normalizeStatusDotChunks(chunks: string[]) {
@@ -54,16 +54,25 @@ function resolveTerminalFontFamily(navigatorValue: {
// Slice only the font block itself (isIOSWebView + terminalFontFamily), anchored
// on font-related markers so unrelated edits below it can't break this extraction.
const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()')
const declarationLine = terminalHtmlSource.indexOf(' scope.terminalFontFamily =', functionStart)
// Ruling 20 put the assignment inside `startTextScaling`, whose earlier statements read
// elements and constants this has nothing to do with. So the declarations come from one slice
// and the font line from another, which is what "only the font block itself" already meant.
const declarationsEnd = terminalHtmlSource.indexOf(' function startTextScaling()', functionStart)
const declarationLine = terminalHtmlSource.indexOf(
' scope.terminalFontFamily =',
declarationsEnd
)
const declarationEnd = terminalHtmlSource.indexOf(';\n', declarationLine) + 1
expect(functionStart).toBeGreaterThanOrEqual(0)
expect(declarationLine).toBeGreaterThan(functionStart)
expect(declarationsEnd).toBeGreaterThan(functionStart)
expect(declarationLine).toBeGreaterThan(declarationsEnd)
expect(declarationEnd).toBeGreaterThan(declarationLine)
const context: { navigator: typeof navigatorValue; output?: string } = {
navigator: navigatorValue
}
new Script(`
${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationEnd)}
${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationsEnd)}
${terminalHtmlSource.slice(declarationLine, declarationEnd)}
output = scope.terminalFontFamily;
`).runInNewContext(context)
return context.output ?? ''
@@ -94,12 +103,13 @@ describe('TerminalWebView text zoom', () => {
it('forces the Claude status dot to text presentation before xterm writes', () => {
expect(terminalHtmlSource).toContain('font-variant-emoji: text')
expect(terminalHtmlSource).toContain('scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210)')
// Ruling 21: the dot's value is in the scope factory, not in a parse-time write.
expect(terminalHtmlSource).toContain('const statusDot = String.fromCharCode(9210);')
expect(terminalHtmlSource).toContain(
'scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)'
'const textPresentationSelector = String.fromCharCode(65038);'
)
expect(terminalHtmlSource).toContain(
'scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)'
'const emojiPresentationSelector = String.fromCharCode(65039);'
)
expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)')
expect(terminalHtmlSource).toContain(
@@ -168,12 +178,16 @@ describe('TerminalWebView text zoom', () => {
it('uses the bundled WebGL-capable xterm stack and platform-safe font fallbacks', () => {
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon')
// C7.5 moved the engine constructors onto the scope so the page can set them; inside the
// document the default still reads the bundled engine, and it is now the preamble that
// carries the read rather than the recovery module.
expect(documentScopePreamble()).toContain('window.WebglAddon.WebglAddon')
expect(terminalWebglRecoverySource).toContain('scope.createWebglAddon()')
expect(terminalHtmlSource).toContain('function isIOSWebView()')
expect(terminalHtmlSource).toContain('fontFamily: scope.terminalFontFamily')
expect(terminalHtmlSource).toContain('fontWeight: "300"')
expect(terminalHtmlSource).toContain('fontWeightBold: "500"')
expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()')
expect(documentScopePreamble()).toContain('new window.WebglAddon.WebglAddon()')
})
const IOS_IPHONE_NAVIGATOR = {
@@ -8,6 +8,13 @@ import {
} from './terminal-write-coalescer'
const webViewSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
// C7.5 moved everything that is not `react-native-webview` into the controller both components
// share, so the coalescer's boundaries are read there; the component is still read for the two
// WebView lifecycle events that reach it.
const controllerSource = readFileSync(
new URL('./use-terminal-webview-controller.ts', import.meta.url),
'utf8'
)
// Simulates TerminalWebView's postMessage: ready → deliver, not ready → queue.
// There is no React render harness in the node environment, so the boundary
@@ -105,27 +112,27 @@ describe('terminal write coalescer boundaries', () => {
})
it('routes handle.write through the coalescer whose delivery posts the write command', () => {
expect(webViewSource).toContain(
expect(controllerSource).toContain(
"createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data }))"
)
const writeStart = webViewSource.indexOf('write(data: string) {')
const writeStart = controllerSource.indexOf('write(data: string) {')
expect(writeStart).toBeGreaterThanOrEqual(0)
const writeBody = webViewSource.slice(writeStart, writeStart + 120)
const writeBody = controllerSource.slice(writeStart, writeStart + 120)
expect(writeBody).toContain('writeCoalescer.write(data)')
expect(writeBody).not.toContain('postMessage')
})
it('clears the coalescer before posting init and clear (snapshot supersession)', () => {
// Anchor on the init() signature (unique) — 'init(' alone also matches comments.
const initStart = webViewSource.indexOf('initialData?: string,')
const initClear = webViewSource.indexOf('writeCoalescer.clear()', initStart)
const initPost = webViewSource.indexOf("type: 'init'", initStart)
const initStart = controllerSource.indexOf('initialData?: string,')
const initClear = controllerSource.indexOf('writeCoalescer.clear()', initStart)
const initPost = controllerSource.indexOf("type: 'init'", initStart)
expect(initStart).toBeGreaterThanOrEqual(0)
expect(initClear).toBeGreaterThan(initStart)
expect(initClear).toBeLessThan(initPost)
const clearStart = webViewSource.indexOf('clear() {', initPost)
const clearBody = webViewSource.slice(clearStart, clearStart + 160)
const clearStart = controllerSource.indexOf('clear() {', initPost)
const clearBody = controllerSource.slice(clearStart, clearStart + 160)
expect(clearStart).toBeGreaterThanOrEqual(0)
expect(clearBody.indexOf('writeCoalescer.clear()')).toBeGreaterThanOrEqual(0)
expect(clearBody.indexOf('writeCoalescer.clear()')).toBeLessThan(
@@ -135,9 +142,9 @@ describe('terminal write coalescer boundaries', () => {
it('flushes pending writes before resize and reflow so boundaries observe prior bytes', () => {
for (const method of ['resize', 'reflow'] as const) {
const start = webViewSource.indexOf(`${method}(cols: number, rows: number) {`)
const start = controllerSource.indexOf(`${method}(cols: number, rows: number) {`)
expect(start).toBeGreaterThanOrEqual(0)
const body = webViewSource.slice(start, start + 300)
const body = controllerSource.slice(start, start + 300)
const flushIndex = body.indexOf('writeCoalescer.flushNow()')
const postIndex = body.indexOf(`postMessage({ type: '${method}'`)
expect(flushIndex).toBeGreaterThanOrEqual(0)
@@ -146,19 +153,26 @@ describe('terminal write coalescer boundaries', () => {
})
it('clears the coalescer in both document-lifecycle hooks alongside pendingMessages', () => {
for (const hook of ['const handleLoadStart', 'const handleContentProcessDidTerminate']) {
const start = webViewSource.indexOf(hook)
expect(start).toBeGreaterThanOrEqual(0)
const body = webViewSource.slice(start, webViewSource.indexOf('}, [', start))
expect(body).toContain('pendingMessages.clear()')
expect(body).toContain('writeCoalescer.clear()')
}
// Both hooks now reach one function, so the clearing is asserted once where it lives and the
// two WebView events are asserted to be the callers. Reading only the component would pass on
// a `resetReadiness` that had quietly stopped clearing either one.
const start = controllerSource.indexOf('const resetReadiness = useCallback')
expect(start).toBeGreaterThanOrEqual(0)
const body = controllerSource.slice(start, controllerSource.indexOf('}, [', start))
expect(body).toContain('pendingMessages.clear()')
expect(body).toContain('writeCoalescer.clear()')
expect(webViewSource).toContain('onLoadStart={resetReadiness}')
const terminated = webViewSource.indexOf('const handleContentProcessDidTerminate')
expect(terminated).toBeGreaterThanOrEqual(0)
expect(webViewSource.slice(terminated, webViewSource.indexOf('}, [', terminated))).toContain(
'resetReadiness()'
)
})
it('clears the coalescer on unmount so no timer leaks', () => {
const cleanupStart = webViewSource.indexOf('useEffect(() => {\n return () => {')
const cleanupStart = controllerSource.indexOf('useEffect(() => {\n return () => {')
expect(cleanupStart).toBeGreaterThanOrEqual(0)
const cleanupBody = webViewSource.slice(cleanupStart, cleanupStart + 160)
const cleanupBody = controllerSource.slice(cleanupStart, cleanupStart + 160)
expect(cleanupBody).toContain('writeCoalescer.clear()')
})
})
@@ -0,0 +1,318 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import { useTerminalWebViewEngineErrorState } from './terminal-webview-engine-error-state'
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
import { useTerminalWebViewReadyPromises } from './terminal-webview-ready-promises'
import { createTerminalWriteCoalescer } from './terminal-write-coalescer'
/**
* Everything `TerminalWebView` does that is not about `react-native-webview`.
*
* The document is the same program on both platforms — inside the WebView it is the generated
* script, on the page it is the modules that script is generated from — so the readiness
* handshake, the pending queue, the write coalescer, the ready and measure promises and the whole
* imperative handle are the same too. What differs is only how a command reaches the document and
* how a notify comes back: a `postMessage` across the WebView bridge, or a direct call.
*
* So that difference is the two arguments, and both components are the small part that is left.
* Writing the page's half as a second copy of this would be the fork the series exists to avoid:
* the handle is the contract every consumer above it holds, and two implementations of it drift.
*/
export type TerminalWebViewTransport = {
/** Hands one command, with its id already assigned, to the document. */
post: (command: TerminalWebViewCommand & { id: number }) => void
/**
* Whether returning to the foreground has to re-prove the document is alive.
*
* iOS can keep the native view while discarding the WebView's JS state, so the native side
* pings and replays nothing until that exact document answers. The page has no second content
* process to lose: its document is the page's own modules, and if they were gone so was the
* component holding this handle.
*
* Asked at the moment of recovery rather than at render, because the platform the native
* component reads is the running one and a handle built once must not cache it.
*/
pingsOnForegroundRecovery: () => boolean
}
export function useTerminalWebViewController(
props: TerminalWebViewProps,
transport: TerminalWebViewTransport
) {
const {
terminalTheme,
textScale = 1,
onWebReady,
onEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
} = props
const { pingsOnForegroundRecovery, post } = transport
const isWebReadyRef = useRef(false)
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
const messageIdRef = useRef(0)
const pendingPingIdRef = useRef<number | null>(null)
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
// Why: each init() call posts 'init' to the document and arms a fresh ready promise. The
// document's init() rAF chain ends with a 'ready' notify that resolves it. measureFitDimensions
// awaits this so it doesn't race ahead of term.open() / renderService population.
const promises = useTerminalWebViewReadyPromises()
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
useTerminalWebViewEngineErrorState(onEngineError)
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
isWebReadyRef,
reportEngineError
)
const sendToDocument = useCallback(
(msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
const id = messageIdRef.current
post({ ...msg, id })
return id
},
[post]
)
const flushPendingMessages = useCallback(() => {
pendingMessages.flush(sendToDocument)
}, [pendingMessages, sendToDocument])
const postMessage = useCallback(
(msg: TerminalWebViewCommand) => {
if (!isWebReadyRef.current) {
pendingMessages.queue(msg)
return
}
sendToDocument(msg)
},
[pendingMessages, sendToDocument]
)
// Why: a busy PTY delivers ~200 stream frames/s; coalescing here collapses the
// per-frame bridge + WebKit IPC + paint cost that runs the phone hot (#9302).
const writeCoalescer = useMemo(
() => createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data })),
[postMessage]
)
useEffect(() => {
return () => {
writeCoalescer.clear()
}
}, [writeCoalescer])
const confirmWebReady = useCallback(
(notifyParent: boolean) => {
pendingPingIdRef.current = null
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
if (notifyParent) {
onWebReady?.()
}
// Why: reload clears queued commands, so readiness must always restore the
// native-selected theme even when its value did not change in React.
sendToDocument({ type: 'set-theme', terminalTheme })
flushPendingMessages()
},
[
clearEngineError,
clearWebReadyWatchdog,
flushPendingMessages,
onWebReady,
sendToDocument,
terminalTheme
]
)
/** One notify from the document, already parsed. */
const receive = useCallback(
(msg: Record<string, unknown>) => {
routeTerminalQueryReply(msg, onTerminalQueryReply)
if (msg.type === 'web-ready') {
confirmWebReady(true)
} else if (
msg.type === 'pong' &&
typeof msg.pingId === 'number' &&
msg.pingId === pendingPingIdRef.current
) {
confirmWebReady(false)
} else if (msg.type === 'ready') {
// Why: the document's init() rAF chain has run — term is open, renderService is
// populated, first paint has happened. Resolve any pending awaitReady() so a queued
// measure can now safely read cell dims.
promises.resolveReady()
} else if (msg.type === 'measure-result') {
promises.resolveMeasure(msg)
} else {
dispatchTerminalWebViewNotification(msg, {
reportEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
})
}
},
[
confirmWebReady,
promises,
reportEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
]
)
/**
* The document is gone or about to be replaced: nothing queued belongs to the next one.
*
* Why: messages queued for a previous generation are stale after a reload; dropping them avoids
* replaying terminal chunks before the next init snapshot.
*/
const resetReadiness = useCallback(() => {
isWebReadyRef.current = false
pendingPingIdRef.current = null
pendingMessages.clear()
writeCoalescer.clear()
armWebReadyWatchdog()
}, [armWebReadyWatchdog, pendingMessages, writeCoalescer])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
}, [postMessage, terminalThemeKey, terminalTheme])
// Why: live-apply text-size changes to an already-mounted terminal (the pane
// stays alive while the user visits Settings), so no terminal reload is needed.
useEffect(() => {
postMessage({ type: 'set-font-scale', fontScale: textScale })
}, [postMessage, textScale])
const handle = useMemo<TerminalWebViewHandle>(
() => ({
prepareForForegroundRecovery() {
if (!pingsOnForegroundRecovery()) {
return
}
// Why: direct ping is the only command allowed through while readiness is
// invalid; init/write commands queue until this exact document answers.
isWebReadyRef.current = false
armWebReadyWatchdog()
pendingPingIdRef.current = sendToDocument({ type: 'ping' })
},
write(data: string) {
writeCoalescer.write(data)
},
init(
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) {
// Why: arm a fresh ready promise BEFORE posting init. The document resolves it via the
// 'ready' notify at the end of its rAF chain.
promises.armReady()
// Why: pending chunks are pre-snapshot data; the init snapshot supersedes
// them, and writing them after init would corrupt the fresh buffer.
writeCoalescer.clear()
postMessage({
type: 'init',
cols,
rows,
initialData,
oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
})
},
resize(cols: number, rows: number) {
// Why: resize/reflow must observe all prior writes or bytes reorder.
writeCoalescer.flushNow()
postMessage({ type: 'resize', cols, rows })
},
reflow(cols: number, rows: number) {
writeCoalescer.flushNow()
postMessage({ type: 'reflow', cols, rows })
},
clear() {
writeCoalescer.clear()
postMessage({ type: 'clear' })
},
measureFitDimensions(containerHeight?: number) {
if (!isWebReadyRef.current) {
return Promise.resolve(null)
}
return promises.measure(sendToDocument, containerHeight)
},
resetZoom() {
postMessage({ type: 'reset-zoom' })
},
cancelSelect() {
postMessage({ type: 'cancel-select' })
},
doSelectAll() {
postMessage({ type: 'do-select-all' })
},
// Why: waits on the in-flight ready promise (set by init); resolves immediately if no init
// is pending, and is capped so a stuck document doesn't hang the caller.
awaitReady: promises.awaitReady
}),
[
armWebReadyWatchdog,
pingsOnForegroundRecovery,
postMessage,
promises,
sendToDocument,
terminalTheme,
textScale,
writeCoalescer
]
)
return {
armWebReadyWatchdog,
clearEngineError,
confirmWebReady,
engineError,
handle,
receive,
reportNativeEngineError,
resetReadiness
}
}
+8
View File
@@ -97,6 +97,14 @@
"file": "src/browser/browser-address-field-styles.web.ts",
"reason": "The address bar renders at the theme's 12px meta size, and in a browser an input under 16px makes iOS zoom the page on focus and never zoom back. keyboard-occlusion.web.ts reads that scale as 'no keyboard' and answers 0, so one focus would stop the pane lifting for the rest of the typing session. This file puts the input and its overlaid label on TEXT_INPUT_FONT_SIZE with a line box to match; the native sibling keeps 12px, which is what a phone has always rendered and where no page can zoom."
},
{
"file": "src/terminal/TerminalWebView.web.tsx",
"reason": "react-native-webview has no web build that renders anything: on the page it paints the line \"React Native WebView does not support this platform\" where the terminal was, which is a red line and no terminal rather than a crash. This file mounts the same document the WebView loads — xterm imported from @xterm/xterm with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical TerminalWebViewProps and TerminalWebViewHandle, so nothing above the contract can tell the two apart."
},
{
"file": "src/terminal/terminal-webview-html.web.ts",
"reason": "The native file composes the whole WebView document, which splices in the 612 KiB minified xterm engine string. On the page that string is unusable — the shell's CSP is script-src 'self' with frame-src 'none', so there is no nested document to load it into — and it would be the largest single module in the session route's closure. The web file answers the caret options, the markup and the stylesheet, which is everything the page mounts, and nothing else; mobile-web-terminal-engine-closure.test.mjs is the fence."
},
{
"file": "src/platform/media-picker.web.ts",
"reason": "expo-image-picker and expo-document-picker are native modules whose import runs a codegen lookup that throws in a browser, and the route manifest imports every route, so one of them in a page closure takes the whole bundle down rather than one picker. This one asks the shell through native.media.pick, reads the bytes back a chunk at a time over native.media.read because a picked image reaches 18 MiB raw against an 8 MiB reply ceiling, and releases every handle it was handed, including the ones its caller never took. The pasteboard is not here: clipboard.web.ts owns it on both platforms and reaches the same verbs for an image."