Skip to content

jpro-sticky: pin with native position:sticky on the web - #129

Draft
streamingpixel wants to merge 37 commits into
mainfrom
sticky-native-position
Draft

streamingpixel wants to merge 37 commits into
mainfrom
sticky-native-position

Conversation

@streamingpixel

@streamingpixel streamingpixel commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Part of #127. Fixes the off-screen parking and the reconnect retargeting; the touch scroll-chaining half is JPro core and stays open.

The web pin used animation-timeline: scroll(). Chrome 115+ and Safari 26+ have it, Firefox doesn't. Without it the pin parked a document-height off-screen, which is what #127 reported.

This uses position: sticky instead, which every engine has shipped for years. Each pin is mounted inside a pane sized to the range it should travel; an injected rule makes the node sticky at its inset, and that pane is its containing block, so the browser derives the release itself. FIXED is the same pin over a pane as long as the document.

Two corrections the sheet carries:

  • Sticky resolves in layout space, so an ancestor transform displaces it. The script reads that shift off the DOM on every render, since the server cannot see what the renderer wrote.
  • An element is a scroll container merely by having non-visible overflow, and <body> usually does. One that can't scroll is cleared.

Verified on the website in Chrome/Firefox/Safari desktop and Android Chrome + Samsung Internet. iOS untested, no device. 108 tests green, including Playwright e2e in Chromium and Firefox.

Draft because one hypothetical core change would make this simpler: splitting the layout/translate fusion in genTransform so a statically positioned node carries no transform at all. Native position: fixed would become viable and FIXED could drop its document-length span.


Review round 1 applied (b7327c4):

  • The overflow rewrite is gone. It lifted a <body> clip that was already handed to the viewport and working, replaced clips with visible instead of clip, and defeated a body{overflow:hidden} scroll lock while any pin existed. The walk now only names the scrollport on the console, and skips <body> while <html> is visible.
  • The example's index.html clipped html and body together, which is what stopped the hand-off. That was the page actually breaking, and it now matches the README.
  • The span's top is back in the re-install signature; without it a pin whose span moves kept a stale offset until the 500ms heartbeat.
  • isOverlay narrowed to the span's style class; the dropped node transforms are documented.

Tobias Horak added 30 commits September 11, 2026 10:48
…supported

On engines without scroll-driven animations -- every Firefox, and Safari
before 26 -- emitting the pin rule anyway is worse than emitting nothing.
The engine drops the unknown `animation-timeline`, `animation-duration:auto`
then resolves to 0s, and `animation-fill-mode: both` snaps the node to the
`to` keyframe. Since that keyframe sits at the release limit (a document
height down for an unbounded pin), every sticky and fixed node is parked
off-screen and the page loses its header, toast, bar and overlay entirely.

Feature-detect the timeline and, when it is missing, emit only the
pointer-events half of the rule. The server-side pin in sync() then drives
the node on its own: refreshed at browserViewport() cadence rather than per
compositor frame, so lower fidelity, but on screen and correct.

Covered by StickyNoScrollTimelineTest, which drives the example in Firefox
(the one engine Playwright ships that lacks the feature) and asserts pinned
nodes are on screen. Without the guard it fails with the header parked at
5951px.

Reported in #127.
jpro-id is a per-view transport index whose counter restarts when a reconnect
builds a new view, so the cached id in the injected style rule did not merely go
stale -- it silently retargeted an unrelated node. Measured against the example
app by closing the websocket to force a real reconnect: five of six rules matched
nothing, and the sixth (a fullscreen overlay) retargeted the bottom bar and
hauled it from top:855 to top:0, taking the overlay's pointer-events with it.

Keep the @Keyframes in the injected sheet but bind the animation inline on the
element itself. A running animation outranks inline declarations in the cascade,
so it still overrides the renderer's own inline transform, and the renderer
writes styles one property at a time so the binding survives its re-renders. An
element reference cannot collide with another node: it only goes stale, which
isConnected detects. A 500ms heartbeat rebinds when the peer detaches, covering
the case where a re-install wins the race against JPro's DOM rebuild and resolves
the outgoing element while it is still connected.

After this the stale entries are inert (elConnected:false) and every pinned
element holds its position across a reconnect.

Note: full recovery still needs a fresh element reference from the server, since
the resolver closes over a per-view JS value slot. Tracked separately.
The compositor tier only exists on Chromium 145+ and WebKit 26+, so engines
without it (Firefox, Safari < 26) fell back to the server-side pin, refreshed at
browserViewport cadence -- correct but visibly behind (~17px drift measured on
localhost, worse over a real network).

Add a second tier that evaluates the same pin function per frame in JS. It is
selected automatically wherever scroll-driven animations are missing, so those
engines now get a real per-frame pin instead of the server-cadence fallback.
The server pin stays underneath both tiers as what picking sees.

Two disciplines keep it cheap: the loop reads only window.scrollY and writes only
when the value changes, so an idle page dirties nothing; and scrollHeight, which
forces layout, is cached and refreshed on the existing 500ms heartbeat rather
than read per frame.

Measured on the example app, header top sampled at five scroll stops and one
frame after each of six jumps -- the worst case for a main-thread pin:
  Chromium, compositor tier: 0 everywhere
  Chromium, rAF forced:      0 everywhere
  Firefox, auto -> rAF:      0 everywhere
So the floor is indistinguishable from the compositor tier on an idle main
thread. Whether the compositor tier earns its keep under main-thread load is the
open question; -Djpro.sticky.pin=raf (or JPRO_STICKY_PIN=raf) forces the floor on
a compositor-capable engine to measure it.
The rAF splice left the method javadoc sitting above the PIN_MODE field instead
of the method, and it still described only the compositor tier. Move the field
above it, rewrite it to cover both tiers, and trim the surrounding comments.
…ir overwrites

Three defects found reviewing the two-tier pin.

Hold the JSVariable from getElement(). It was fetched for its name and dropped on
the same line, so once GC collected it JPro's default cleanup sent
"jpro.var_N = undefined" and the resolver baked into the injected script returned
null forever. Pre-existing, but the rebind heartbeat made it far worse: the
resolver is now retained and re-invoked exactly when a rebuild needs it, so a GC
could silently disarm the recovery path. A fresh slot is still minted per install
because the defining command is one-shot per view.

Gate on every declaration the rule needs, in the form actually emitted:
animation-timeline:scroll(root block), animation-duration:auto and
animation-range. The old gate probed animation-timeline:scroll() alone, but the
failure in #127 was duration:auto falling back to 0s with fill-mode:both snapping
the node to the 'to' keyframe. An engine shipping scroll() while rejecting either
of the others would have reproduced it exactly. Also read back getComputedStyle
after applying and fall through to the rAF floor if the engine did not keep the
declarations, so partial support degrades instead of parking the node off screen.

In the rAF tick, compare against the element's inline transform as well as the
last computed value. The renderer writes the same property from the server pin,
so when scrolling stopped and ty was unchanged the overwrite was never repaired
and the node kept the lagging server position.
Both of these turned a bad input into a silent failure with nothing in any log.

The pin mode is read from a system property or environment variable and
interpolated into the injected script, so validate it to exactly "auto" or "raf"
rather than passing it through. A stray quote made the whole script a syntax
error, which stops every pin on the page installing with no server-side signal;
a trailing newline, easy to get from a shell heredoc or a Docker ENV, compared
unequal to "raf" and silently read as "auto", so an A/B run would measure the
compositor tier while appearing to force the floor. Input is now trimmed and
lowercased, and anything unrecognised logs a warning and falls back to auto.
There is deliberately no way to force the compositor tier, since on an engine
that lacks it that parks the node off screen.

Refuse to install when any of the five interpolated geometry values is not
finite. NaN and Infinity are valid JS literals, so the script installed cleanly
and then failed silently: every clamp escapes its guard because comparisons
against NaN are false, and the resulting transform is rejected by the CSS parser,
leaving a dead pin with no diagnostic. It now logs the offending values and skips.
The pin function is a clamp, so each branch of it is a static positioning
mode: page-anchored below the pin line, fixed at the inset between the
lines, page-anchored again past the release. The affix tier says that
outright and runs JS at the two crossings only, so between them the engine
scrolls the node itself. Firefox scrolls on its compositor while the rAF
tier ran on the main thread, which is why per-frame work was smooth nowhere
it mattered.

position:fixed only means the viewport while no ancestor is a containing
block for it, so the tier is gated on a walk up the DOM for transform,
filter, perspective, will-change and paint containment. Where the gate
fails the compositor and rAF tiers stay as they were.

The placement goes in the pin's stylesheet with !important rather than
inline: the renderer writes the server pin to the same inline transform,
and an important author rule is the only declaration that outranks it.

jpro.sticky.pin now takes fix, css or raf to force a tier.
A scroll event is not prompt enough on Firefox: the compositor scrolls
ahead of the main thread and delivers the event late, so the node rode a
few frames past the pin line before the switch landed, which reads as a
small jump. The event now only arms a frame loop, which reads the scroll
position (already updated from the compositor when a frame callback runs)
and catches the crossing on the frame it happens. The loop reads and
compares, writes only at a crossing, and stops once the page has stopped
moving.

Also adds a page-wide hook to move every pin onto one tier at runtime, so
the tiers can be compared back to back on the same content, and the live
override now outranks the system property so a re-install keeps the
choice.
Three toggles pinned under the page header, one per web tier, applied to
every pin at once. The bar is itself sticky, so it demonstrates whichever
tier is selected. The browser console reports the tier each pin actually
resolved to, which is where a fallback shows up.
Switching to fixed positioning moves the element's containing block from
the overlay to the viewport, so the affix tier needs the node's document X
rather than its overlay-local one. It measured that off the element the
first time it needed it, which held until a tier switch: leaving the rAF
tier strips the inline transform it owned, so the measurement ran against
an untransformed element and read 0, and every pin stacked up on the left
edge until a reload.

The server already knows the answer. It now bakes the overlay's horizontal
origin alongside the vertical one, and the pinned placement is x plus that,
same as the other two states are x alone. Nothing reads the element back,
so there is no moment at which the read can be wrong.
The requestAnimationFrame tier is gone. It evaluated the pin on the main
thread while Firefox scrolls on its compositor, so it was never smooth on
the one engine it existed for.

Order is now compositor keyframes first, affix second. Both are smooth
between the pin lines, but the compositor tier has no crossing to catch:
the engine evaluates the clamp itself, where affix flips state from a
frame callback and so lands the pin a frame late.

With neither available the node stays on the server pin, correct but only
as current as the last viewport update. That is Safari < 26, where the
renderer sets will-change:transform on every div and so fails the affix
gate; a core change there would retire the case.

jpro.sticky.pin now takes css or fix. The sample's third tab is Auto.
A captured position:fixed looks exactly like a pin that is simply broken,
with nothing anywhere to say why, and the cause is normally an ancestor
nobody was thinking about. The gate now returns the offending declaration
instead of a bool, and forcing affix where it cannot hold warns with the
element and the property rather than failing silently.

Also corrects the class doc: the affix gate fails on every Safari version,
not on Safari before 26. It has nothing to do with fixed positioning
support and everything to do with the renderer setting will-change on
every div when it detects Safari. Scroll timelines are the thing that
arrived in 26.
Which tier a given engine lands on was invisible from the outside, so the
only way to answer it was to read the renderer source and infer, which has
already produced one wrong answer about Safari. Each pin now logs its tier
once per change along with the two inputs the choice is made from: whether
the engine has scroll timelines, and what captures position:fixed if
anything does.

The scroll-timeline read-back also says so when it fires, since an engine
that accepts the rule and then does not resolve it is otherwise a silent
downgrade.
Measured on Safari 26.4: two ancestors of a pin carry will-change:transform,
the pin's own overlay and one renderer div, and they are the only reason
position:fixed does not resolve to the viewport there. Clearing just those
two makes every pin hold.

will-change is a hint, so dropping it costs a compositing layer and nothing
else. transform, filter, perspective and contain are not hints, so an
ancestor carrying one is left alone and the pin falls through to another
tier rather than having the page rearranged under it.

This undoes JPro 412c150b (2020, "fixed rare safari rendering bug") for the
ancestors it touches. That commit promotes every node div to its own layer
on Safari and thereby makes each a containing block for fixed positioning;
what the original artifact was is not recorded anywhere, so it may come
back on this subtree. The properties are restored when the last pin on the
page goes away.
…t it cannot do

The two tiers fail in different places and affix fails in the rarer one.
Affix needs position:fixed to resolve to the viewport, which a transformed
ancestor defeats. The compositor tier needs scroll timelines, which Firefox
does not have at all and Safari paints wrong despite placing correctly
(measured: zero deviation from the clamp through a fast scroll, and still
visibly flickering).

So affix goes first, and the compositor tier covers the ancestor case.
Measured both ways in Chrome: a clean chain resolves to affix, a transform
on an ancestor falls through to css rather than to the server pin, and the
compositor tier is indifferent to that ancestor (zero deviation with and
without it).

Reaching the server pin now needs a transformed ancestor and an engine
without scroll timelines at the same time.
… core change

Florian's recollection of 412c150b is that the layer promotion was for
performance, not to fix a correctness artifact. That inverts the risk:
dropping the line in core would demote every node div, while clearing it
per pin demotes the few containers between one pin and the document, and
leaves the pinned node's own layer alone.
The tier explanations had grown into narration that restates the code and
duplicates the class doc. Only the will-change clear keeps more than two
lines, because it records a decision and a conversation that are not
recoverable from the code.
lowercase after a full stop, as the surrounding comments do, and a few
phrasings reworded to read less clipped. no em dashes anywhere in the
module.
The scroll-timeline tier is gone. It only ever worked on Chrome: Firefox
has no scroll timelines at all and Safari paints them badly, so it was a
Chrome-only path rather than the fallback it was described as.

What is left is the affix state machine, which is measured working on
Chrome, Firefox and Safari. Where an ancestor captures position:fixed and
the capture is not a will-change hint we can clear, the node stays on the
server pin and says so on the console.

Also drops jpro.sticky.pin, the page-wide tier switch hook, and the
sample's tier tabs, which have nothing left to choose between.

Three fixes to the affix tier:
- the pinned placement is viewport-relative, so it is now rewritten when
  the page scrolls sideways, not only when the state changes. The frame
  loop watches both axes.
- cleared will-change is found back by attribute instead of from a global
  list, which was retaining the ancestors of every route already left.
- the geometry is scene coordinates compared against document scroll, so
  the two have to coincide. An embedded jpro tag is now detected and left
  on the server pin rather than silently pinned to the wrong place.
The tier was chosen once per install and never revisited, so a blocker
that is only there for a moment became a permanent downgrade. JPro filters
the whole scene while reconnecting, and a reconnect is also what triggers
a re-install, so the two can coincide and leave the pin on the server
cadence for the rest of the session.

The heartbeat now re-picks while a pin is blocked, and the warning is
emitted once per distinct blocker so the retry cannot fill the console.
A STICKY pin is now a real position:sticky box inside a per-pin span, so the
browser does the crossing on the compositor and nothing runs per scroll. The
span is an FX pane mounted with the node, sized from the pin's scroll range, so
release falls out of the containing block rather than from a threshold check.

Sticky resolves in layout space, so only an ancestor transform displaces it;
that shift is measured in the browser rather than baked, because the fixed path
rewrites those transforms as it runs. The rule lands on the span's own child:
JPro nests the node below it and getElement() resolves to the inner element.

FIXED keeps the scripted path. It never crosses a threshold, but position:fixed
is still captured by a transformed ancestor, which that script clears.
The server pins the node at the position it appears at, because picking is a
scene pick and a node parked at the span origin is not where the tap lands. The
renderer writes that offset out below the pin, so the sticky rule clears the
transform on its own element and on the child that carries it: the rule places
the box, and the offset would otherwise be added on top of it.
position:sticky holds against the nearest scroll container, and an element is
one whenever its overflow is not visible, whether or not it can scroll. body is
often such an element, so the pin held against a viewport that never moved and
rode the page instead.

Cleared on the ancestors that cannot scroll at all, where it changes nothing
else, and restored on teardown. Both axes go together: visible on one computes
back to auto while the other clips.
A fixed pin is the same pin over a span the length of the document. A
viewport-anchored node fits the viewport, so that span's end is out of reach and
the pin never releases; a node taller than the viewport breaks that and now says
so. One install path, and the compositor holds both kinds of pin.

This drops what the scripted path existed for: the affix state machine and its
frame loop, the tier choice, and the transform rewriting that position:fixed
needed to survive a transformed ancestor. position:sticky resolves against the
scrollport instead of a containing block, so none of it is load-bearing any more.

isOverlay learns about the span: it is how attach tells a node still held by a
superseded application from one in its flow slot, and the node now sits a level
deeper.
The sheet now bakes only the inset and the box: the span's extent, the
node's offset and the overlay host's origin are all server-side layout.
Narrow the re-install signature and the finite check to match, and rename
what the animation tiers left behind.
An embedded jpro tag can sit inside a real scroller. Sticky then holds
against that scroller while the anchor offset was resolved against the
browser viewport, so the two disagree. Unclipping stops there as well:
above the nearest scroller nothing affects the pin.
Describe the real web mechanism (a pane sized to the travel range, made
position:sticky), note that the node sits one level deeper because of it,
and stop recommending a body overflow that makes every pin hold against a
viewport that never moves.
A clip only has to touch one axis to make an element a scrollport, so a
body clipped horizontally owns the pin and cannot move it. Testing
scrollWidth alongside scrollHeight let that case skip the unclip, and the
early return then stopped the walk before the ancestors above it. Warn
instead when lifting a clip that was really holding content back.
Nothing emits animation-timeline any more, so the class name, the docs and
the CSS.supports precondition all described machinery that is gone. The
precondition would also have failed the test outright once Firefox ships
scroll-driven animations. Keep the coverage, which is the engine #127 was
reported against, and tighten the comments the rewrite left behind.
Tobias Horak and others added 7 commits September 21, 2026 18:10
Leftover wording from the keyframe design, where the geometry really was
written into the rule at install time. Also drops a comment still crediting
the deleted transform rewriting for why the shift is read live.
Rewriting the host page's overflow was wrong three ways. It read computed
style alone, so it lifted a body clip that was already handed to the
viewport and working; it replaced the clip with visible rather than clip,
so a real horizontal clip started scrolling; and it defeated a
body{overflow:hidden} scroll lock, the standard modal pattern, for as long
as any pin existed. The walk now names the scrollport on the console and
touches nothing, skipping body while html is visible.

The example's own index.html was the page that needed the fix: it clipped
html and body together, which is what stops the hand-off. It now matches
what the README tells users.

Also restores the span's top to the re-install signature. It moves the
ancestor transform the script measures, so without it a pin whose span
shifts keeps a stale offset until the heartbeat catches it half a second
later. Narrowing the signature had dropped it.

Tightens isOverlay to the span's own style class rather than any
grandchild, and documents that a web pin drops the node's own transform
properties, which JPro fuses with the layout position.
The ancestor transforms sticky ignores sum to the span's scene y, which the server already has. Write it into the rule instead of measuring it in the browser; the heartbeat now only re-binds after a DOM rebuild. Example and README use overflow-x: clip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JuPMUKNXzA35Hjuahzvvn
FX ids reach the DOM only with jpro.mirrorCSSToDOM, which is off by default, so the pin never bound in a plain app. Resolve the span with WebAPI.getElement and add a test that runs the example with the default config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JuPMUKNXzA35Hjuahzvvn
…bind

The default-config test now also checks the section header's non-zero span offset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JuPMUKNXzA35Hjuahzvvn
…ayMount

The web impl's scene and settle waiters were unreachable under the WebAPI consumer, which fires only once the node is rendered in a scene. The stuck channel now resets in the dispatcher alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JuPMUKNXzA35Hjuahzvvn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants