Skip to content

fix(mobile): fire a swipe on membership of the declared direction set - #9689

Draft
os-justin wants to merge 1 commit into
mainfrom
claude/issue-7974-swipe-direction-honours-the-declared-set
Draft

os-justin wants to merge 1 commit into
mainfrom
claude/issue-7974-swipe-direction-honours-the-declared-set

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Part of #7974

Executes the maintainer ruling of decision batch #70 (issue comment 5565631072, verbatim 「同意」): SwipeGestureConfig.direction stays as declared, and the implementation aligns to it — the swipe fires when the detected direction is a member of the declared set, the scalar branch with its as string cast is removed (AGENTS.md #0.1), and the shipped @example moves to the array form. Both halves, one change.

Half 1 — the @example

Before:

config: { type: 'swipe', enabled: true, swipe: { direction: 'left', threshold: 80 } },

After:

config: { type: 'swipe', enabled: true, swipe: { direction: ['left'], threshold: 80 } },

Half 2 — the lenient cast, removed with nothing in its place

const dir = Array.isArray(config.swipe?.direction)
  ? config.swipe?.direction[0]
  : (config.swipe?.direction as string | undefined);
gestureType = (dir ? SWIPE_DIRECTION_MAP[dir] : undefined) ?? 'swipe-left';

Three separate leniencies lived in those four lines: a scalar admitted through a cast that the declared array type rejects; direction[0], which honours one element of a declared many; and a ?? 'swipe-left' default that recognized a direction nobody declared. All three are gone and none is replaced.

What replaces them reads the set as a set. Recognition is the any-direction move past the threshold — the recognizer useGesture already implements — and the callback fires only when the detected direction is a member of config.swipe.direction:

const declaredDirections: readonly string[] = config.swipe?.direction ?? [];
gestureType = 'pan';
threshold = config.swipe?.threshold;
onGesture = (ctx) => {
  const detected = ctx.direction;
  if (detected === undefined || !declaredDirections.includes(detected)) return;
  ...
};

This does not collapse swipe into pan: pan fires for every direction, swipe fires for the declared members only. Sharing the recognizer is what lets one configuration honour several directions at once, which a direction-fused recognizer cannot do by construction.

Three consequences worth reading as behaviour changes rather than refactors, all of them the ruling applied:

  • A set with more than one member now fires for all of its members. direction: ['left', 'up'] used to fire on a left swipe only.
  • An empty or absent swipe sub-object now fires for nothing, where it used to fall back to a left-swipe recognizer.
  • The onGesture fallback payload now reports type: 'swipe' — the spec gesture — where it used to report the recognizer's own name. The recognizer's type travels inside the context object at runtime, so the previous spread order let it overwrite the declared type; with the shared recognizer it would have said pan. type now goes last. Pinned.

SWIPE_DIRECTION_MAP (direction to recognizer) is deleted with its last caller. The SPEC_GESTURE_TYPE_MAP entry for swipe is unchanged in value and its comment now says what it is: a placeholder, because no single recognizer name carries "a set of directions". That map's own JSDoc, which routed swipe through the deleted map, is repaired in the same edit.

The precondition: scalar callers = 0, with a firing control

The ruling asked for the count and for a probe demonstrated to find a scalar caller when one exists. Both numbers:

run SCALAR ARRAY INDIRECT
tree as-is, before the fix 1 1 0
same tree, one synthetic scalar caller planted 2 1 0
tree after the fix 0 2 0

The probe walks 5,354 files under packages/, apps/ and examples/, matches every swipe: { ... } literal plus every .swipe.direction = assignment, and classifies the value by shape. The firing control planted one file containing swipe: { direction: 'right', threshold: 80 }, proved it reached disk with a grep -c, and the probe's SCALAR score moved 1 to 2 and named the planted file. The control file was then removed and the worktree proved clean before the count was taken again.

The one pre-fix SCALAR was the @example this card repairs — not a call site. So: zero scalar callers, and the zero is a reading of the tree, not of the pattern.

The probe's blind spot, closed by hand rather than assumed away: a SwipeGestureConfig built as a standalone annotated literal is invisible to a swipe: block pattern. A census of every SwipeGestureConfig mention under those three roots returns the interface declaration, two type re-exports, four CHANGELOG paragraphs, and — after this change — the deliberate rejection case in the new test. No annotated literal anywhere else.

The membership test

packages/mobile/src/__tests__/spec-gesture-direction-set.test.tsx, five pins, run against the real useGesture through synthetic touch events, because the recognizer choice and the membership filter are two halves of one behaviour and mocking the recognizer pins only the half that lives in the hook:

  • fires for EVERY member, not just the first
  • does not fire for a direction the set does not declare
  • an EMPTY declared set fires for nothing, it does not default to left
  • the fallback reports the SPEC gesture and the detected direction
  • the declared type admits no scalar — a @ts-expect-error on a scalar direction

That last one is a type-level pin and it is really checked: packages/mobile's tsconfig.test.json includes the file (confirmed with --listFiles) and resolves @object-ui/types through the built declaration, not through source. Control run, with the directive stripped:

error TS2322: Type 'string' is not assignable to type 'SpecSwipeDirection[]'.

the same code the @example ledger row used to declare, now pinned as an expected rejection.

Ablation

Fix committed first, then packages/mobile/src/useSpecGesture.ts restored to its pre-change blob, both legs verified by git hash-object against the recorded blob hashes rather than by an exit code.

leg on-disk markers result
without the fix SWIPE_DIRECTION_MAP 4, scalar example 1, declaredDirections 0 2 files failed — 4 failed, 7 passed (11)
with the fix SWIPE_DIRECTION_MAP 0, scalar example 0, declaredDirections 2 2 files passed — 11 passed (11)

The four that go red without it:

× fires for EVERY member, not just the first
× an EMPTY declared set fires for nothing — it does not default to left
× the fallback reports the SPEC gesture and the detected direction
× swipe recognizes ANY direction — the declared set is filtered, not fused into the recognizer

with the discriminating failure reading:

AssertionError: a member after the first — this is what `direction[0]` dropped:
expected "vi.fn()" to be called with arguments: [ 'up' ]
Number of calls: 0

"does not fire for a direction the set does not declare" passes on both sides and is kept anyway: the pre-fix recognizer refused a non-member for the wrong reason (it was fused to one direction), so that assertion cannot tell the two implementations apart. Said here rather than left for a reader to discover.

The @example gate's ledger row is RE-DERIVED, not deleted

scripts/check-doc-example-types.mjs already compiles this block, and its row for it said "Delete this row when that card lands". That instruction is falsified by measurement. The repair pays off the row's TS2322 half; the block still returns outside any function, so TS1108 remains and the block is still a fragment by shape — exactly the row the two other hook-body excerpts in this package carry. Deleting the row would have turned the block into an undeclared failure, which is red for a different reason. The row is now codes: [1108], card: null, with the siblings' wording; the gate's own header paragraph, which described the card as open, is rewritten to record what actually happened. The gate agrees: pnpm check:doc-examples exits 0, "Every covered @example compiles, or fails exactly as its ledger row declares."

Verification

check result
pnpm exec vitest run packages/mobile/ 4 files, 28 tests passed
pnpm exec vitest run scripts/__tests__/check-doc-example-types.test.ts 61 tests passed
filtered build (--build-filter, 35 tasks) exit 0
pnpm check:doc-examples exit 0 — 124 blocks, 35 compile, 89 fail, 89 declared
pnpm check:doc-snippets exit 0
pnpm --filter @object-ui/mobile type-check exit 0
node scripts/check-changeset-presence.mjs exit 0
node scripts/check-new-cross-file-line-citations.mjs exit 0, 0 new citations
node scripts/check-control-bytes.mjs exit 0
node scripts/check-governed-queue-guard.mjs --test (6 paths) NOT GOVERNED
eslint over the six changed files exit 0

Build and gates ran serialized through the shared heavy-verify lock; the repo-wide pnpm lint and the full suite are CI's.

Changeset

Followed the gate rather than guessed. node scripts/check-changeset-presence.mjs scored two published source files of one released package and refused the change without a declaration; .changeset/7974-swipe-direction-honours-declared-set.md declares @object-ui/mobile: minor (never major — fixed group) and the gate now exits 0. The body states the two breaking narrowings and records that the caller scan found none to migrate.

Scope

The declared type is untouched — packages/types/src/mobile.ts has no edit in this diff, per the ruling and around live work in that file. No gate for JSDoc @example blocks was built: one already exists and this change only re-derives its ledger row. Clause ②: no^\+export scores 0 over the diff and no key is added to any published payload.


Generated by Claude Code

… (objectui#7974)

`SwipeGestureConfig.direction` is declared `SpecSwipeDirection[]` — a set — and
`useSpecGesture` now honours the whole of it: it recognizes the any-direction
move past the threshold and fires only when the DETECTED direction is a member
of the declared set. It used to fuse `direction[0]` into one direction-specific
recognizer, so every element after the first was declared and never honoured,
and an empty or absent set silently defaulted to a left swipe.

The scalar branch and its `as string` cast are gone, and nothing replaces them
(AGENTS.md #0.1): the declared type rejects a scalar and no runtime path
re-admits one. The shipped `@example` moves to `direction: ['left']` in the same
change, because repairing either half alone leaves the other teaching the wrong
contract.

The `@example` gate's ledger row is RE-DERIVED rather than deleted — the repair
paid off its TS2322 half and left the TS1108 hook-body excerpt its two siblings
in this package are declared for.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EpHzwH4wTy5sd7ibkD2yq
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3048.9 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-DbLzpmc-.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.84KB 130.66KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.27KB 62.92KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.26KB 1.99KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.49KB 19.99KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.29KB 65.88KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.64KB 57.91KB
plugin-kanban (index.js) 46.41KB 14.49KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.04KB 21.01KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 104.82KB 34.67KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants