Repository navigation
fix(mobile): usePullToRefresh arms on a host that mounts after the first render (objectui#10105) - #10230
Conversation
…after each commit The hook bound its native touch listeners in an effect keyed only on its handlers and `enabled`. That effect read `ref.current` once. A consumer whose first commit is a loading screen had no host yet, and the effect never ran again once the host appeared, so the gesture was dead on the internal-fetch path. The binding now runs after every commit and re-binds only when the target element changes (DOM identity). Stable listeners dispatch to the latest handlers through a ref. An unmount effect releases the last binding. The exported signature and return type are unchanged. Pins: the ObjectCalendar two-arm pin (internal fetch and pre-fetched) and a hook-level pin (late host, replaced host, unmount, enabled toggle). Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
…rictMode pin The changeset states the product-visible effects measured on this branch: nothing changes in what renders while loading, and inside ListView's grid view the grid's own pull indicator now appears next to ListView's (calendar and timeline under ListView already showed two). One pull still triggers one refetch. The hook-level pin gains a StrictMode case. StrictMode's simulated unmount releases the binding, so the per-commit effect has to bind again, and exactly once. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
StrictMode double-invokes effects on mount only, so a late host never exercised the release-and-rebind path. The case now mounts with the host present. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
Types only; the runtime behaviour of the pin is unchanged. The doubles implement just the two members the component calls and are passed `as never`. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Pull hosts nest: ListView has one and so does the view it renders inside it. A touch on the inner host bubbles to the outer one, so both drew an indicator for the same pull. While bound, the hook now marks its element with data-pull-to-refresh-host, and at touchstart an inner host lets the gesture go when an armed pull host is its DOM ancestor. One pull inside ListView now draws one indicator and runs one refetch. A view with no pull host above it keeps its own pull. The exported signature and return type are unchanged. Pins: the hook pin gains nested cases (outer owns, a disabled or disarmed outer hands the gesture back, a pull on the outer stays the outer's). A new apps/console pin renders ListView over the real calendar, grid and timeline (one indicator, one refetch each) and standalone grid and timeline (still arm and refetch). It lives in apps/console, the one package that declares all four plugins. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
ℹ️ Console Performance Budget — not measuredThis run did not produce a console bundle to measure, so there is no pass/fail verdict for the performance budget. This is not a budget violation. Nothing was measured — the numbers a real violation would carry are simply absent.
See the workflow run for details. 📦 Bundle Size Report
Size Limits
|
Reading `.ref` and `.pullDistance` off the returned object tripped react-hooks/refs. Destructuring matches how consumers call the hook. No runtime change. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
ℹ️ Console Performance Budget — not measuredThis run did not produce a console bundle to measure, so there is no pass/fail verdict for the performance budget. This is not a budget violation. Nothing was measured — the numbers a real violation would carry are simply absent.
See the workflow run for details. 📦 Bundle Size Report
Size Limits
|
…wner pin apps/console type-checks with noUnusedLocals and the automatic JSX runtime, so the import is an error there (TS6133). Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Generated by Claude Code |
Contract reviewServed-tier: Rendered by an isolated review subagent spawned by this seat and fed only the card, the seat's decision ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Seat note: the over-general sentence in ② ships in a CHANGELOG, and objectui#10225 was held to that standard. ⇒ a one-sentence wording fix is requested from the dev (「shows one indicator (calendar and timeline used to show two; the grid view keeps its one)」). Landing waits on objectui#10229 regardless, so this costs no time. A new record will name the new head. Generated by Claude Code |
… too Calendar and timeline under ListView used to show two indicators; the grid view showed one at BASE only because its listener was dead, and keeps one. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: This supersedes the record ① Derived judgmentsCarried over unchanged because no code moved. The exported signature and return type of ② Semver level
③ Boundary flagsCarried over: the Acceptance notes are true in code, the Implemented-by: VERDICT: PASS Landing: this PR stays draft until Generated by Claude Code |
…lendar-pull-to-refresh-arms
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #10105
Clause-②: no
Implemented by the
domain:uiseat #1 dev run, sessionhttps://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC. Generic type parameters are written in words below (for example "useRefof T"), because GitHub strips angle-bracket fragments from PR bodies.What was wrong
usePullToRefresh(@object-ui/mobile) returns an object ref, so the hook never learns when a consumer attaches it. It bound its nativetouchstart/touchmove/touchendlisteners in an effect keyed only on its three handlers andenabled, and that effect readref.currentexactly once.On
ObjectCalendar's internal-fetch path the first commit is the loading screen. That screen carries no ref, so the one read foundnull. When the rows arrived the host mounted, but none of the effect's keys had changed, so the effect never ran again. The pull gesture did nothing, with no error, warning or hint. With pre-fetcheddatathe first commit already contains the host, so the same gesture worked. That is the asymmetry the card reports.The repair (producer-side, in the hook)
The binding now follows the element, not the mount:
ref.current, or null while disabled) differs from the element they are on. This compares DOM identity, never memoised identity (AGENTS.md §5 [WIP] Enhance every detail of the designer #10).pullDistancestep during a gesture, no longer re-binds anything.⛔ The exported signature and return type are unchanged. The declaration emitted for
usePullToRefresh.tsat BASE62597c58and at HEAD is byte-identical: the same isolatedtsc --declaration --emitDeclarationOnlyover the file gives md5dc757400f43d65d92b5816029a7e100ffor both, andcmpexits 0. The returnedrefis still the object ref (RefObjectof T or null) that consumers pass toref=.ObjectCalendar.tsxand the three sibling consumers are not edited.Both arms, one run, file state verified in the same command
The pin is
packages/plugin-calendar/src/ObjectCalendar.pullToRefresh-10105.test.tsx. It drives the hook's own native touch sequence on the host (touchstart at y=10, touchmove to y=110, then touchend), not a scripted scroll.7b6a0926(= HEAD blob = BASE blob, zero diff), first measurement before any edit7b6a0926(ablation mutation leg, at HEAD875c9001)5da0d858(the fix; restore leg, blob == HEAD blob,git diff HEADempty)On the internal-fetch arm the pin also checks that a release past the threshold refetches (
find1 → 2). The secondfindis held open, so the test sees the loading screen with no calendar region. After the rows return, a second pull arms again and refetches (find3).Which sibling consumers shared the defect (measured, not read)
A one-shot probe, not committed, drove the same gesture on each consumer's host on its internal-fetch path. Effects were flushed first, and the readings were written to a file. It ran in both ablation legs:
ObjectGridfindstays 1find1 → 2)ObjectTimelinefindstays 1find1 → 2)ListViewObjectGridandObjectTimelinerender a loading screen before their host, like the calendar.ListViewrenders its host on the first commit and was never affected. Their test suites were re-run (below); their sources were not edited.Product-visible changes: read this before flipping ready
ObjectCalendar,ObjectGridandObjectTimeline.ListViewbubbles toListView's host. An inner host lets the gesture go when an armed pull host is its DOM ancestor. Indicators per pull insideListView(calendar / grid / timeline): BASE 2/1/2, round-1 head875c90012/2/2, head8155fe271/1/1, with one refetch each throughout. While bound, armed hosts carry adata-pull-to-refresh-hostattribute (not exported, not a documented contract). This follows the seat's decision on objectui#10105 (comment5809834105).Ablation (one-shot; no permanent mutation test left behind)
The fix was committed first. The mutation is the whole hook file reverted to its BASE content. It was verified on disk by blob hash (== BASE blob
7b6a0926),bindPullListenerscount 0, and the old dependency-list line count 1. The restore wasgit checkout HEAD -- ABS_PATHbehind an EXIT/INT/TERM trap, and was verified by blob == HEAD blob5da0d858withgit diff HEADempty.packages/mobile/distexisted during the final run, but the root vitest config aliases@object-ui/mobiletosrc, and the mutation leg going red with a fixeddistpresent shows thatsrcis what was measured.At HEAD
875c9001:enabledcases.Two more one-shot mutations went through
ablation-replace.mjs(anchor hit, blob moved, restore proven). They show the StrictMode case can fail:onRefreshis called 2 times, and the unmount case goes red too.Owner ablation at
8155fe27.ablation-replace.mjsWRAP removedif (armedPullHostAbove(el)) return;(1 → 0 hits; hook blob104543d2→6aff16e7). Mutation leg: 5 red (the 3 console nested pins and the 2 hook nested cases, all "got 2" indicators) and 11 green (the standalone grid and timeline, both calendar arms, and the late-attach, replace, StrictMode, unmount and enabled cases). Restore: blob == HEAD104543d2,git diff HEADempty; 16/16 green.Tests and gates (final head
8155fe27, tree clean)pnpm exec vitest run --maxWorkers=3 packages/mobile/ packages/plugin-calendar/ packages/plugin-timeline/ apps/console/src/__tests__/pullToRefreshOwner-10105.test.tsx: 76/76 files, 685/685 tests, exit 0pnpm exec vitest run --maxWorkers=3 packages/plugin-grid/: 146/146 files, 1386/1386 tests, exit 0pnpm exec vitest run --maxWorkers=3 packages/plugin-list/: 82/82 files, 1005/1005 tests, exit 0pnpm --filter @object-ui/mobile type-check,pnpm --filter @object-ui/plugin-calendar type-checkandpnpm --filter @object-ui/console type-check: each exit 0. They ran afterturbo run build --filter=@object-ui/console^...(34/34 tasks). The console type-check is owed because it compiles the new consumer pin.pnpm check:control-bytesexit 0;pnpm check:new-line-citationsexit 0 (0 new citations, merge base62597c58, 7 files compared);pnpm check:phantom-depsexit 0node scripts/check-changeset-presence.mjsexit 0;node scripts/check-changeset-no-major.mjsexit 0;pnpm check:changeset-claimsexit 0node scripts/check-governed-queue-guard.mjs --testover the 7 changed paths: NOT GOVERNEDeslint --format jsonover the 7 changed files: 0 errors, and 0 warnings in new or changed code. The two.mdfiles are config-ignored; the 7 warnings are in the pre-existing parts of the 10031 test. There is no type-aware linting, so this diff cannot move an untouched file's verdict. The repo-widepnpm lint, and the full app-shell and console suites, are left to CI.Acceptance notes
ObjectCalendar.localizedSurface-10031.test.tsx: only its comment changed. It described the dead internal-fetch path as a live defect and now points at the new pin.useGesturein the same package binds in the same kind of mount effect over an object ref. It has zero in-repo consumers (dormant), so it is not filed and not touched here. Carrier: none.ObjectCalendar's own refresh bumps a key its fetch ignores. InsideListViewthe gesture is nowListView's alone, so that inert refresh no longer runs there. A pre-fetched calendar with no pull-hosting parent was not measured; this is an observation, not filed.isRefreshing, or itsscrollTop).ListView's host is overflow-hidden, so it engages; other nestings were not measured.apps/console, the one package that declaresListViewplus all three child plugins (objectui#4409 dependency direction). It sits outside the claim's named surface, and the seat accepted it on review..changeset/10105-pull-to-refresh-late-host.md, a patch for@object-ui/mobile. The package README gains one paragraph saying the ref may attach late or be replaced.Generated by Claude Code