jpro-sticky: pin with native position:sticky on the web - #129
Draft
streamingpixel wants to merge 37 commits into
Draft
streamingpixel wants to merge 37 commits into
streamingpixel wants to merge 37 commits into
Conversation
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.
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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018JuPMUKNXzA35Hjuahzvvn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: stickyinstead, 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.FIXEDis the same pin over a pane as long as the document.Two corrections the sheet carries:
transformdisplaces it. The script reads that shift off the DOM on every render, since the server cannot see what the renderer wrote.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
genTransformso a statically positioned node carries no transform at all. Nativeposition: fixedwould become viable and FIXED could drop its document-length span.Review round 1 applied (
b7327c4):<body>clip that was already handed to the viewport and working, replaced clips withvisibleinstead ofclip, and defeated abody{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.index.htmlclippedhtmlandbodytogether, which is what stopped the hand-off. That was the page actually breaking, and it now matches the README.isOverlaynarrowed to the span's style class; the dropped node transforms are documented.