Skip to content

iOS parity round 5: speed and native polish - #269

Merged
Programcode merged 12 commits into
feat/native-iosfrom
native-ios/r5-speed-polish
Oct 29, 2026
Merged

Programcode merged 12 commits into
feat/native-iosfrom
native-ios/r5-speed-polish

Conversation

@Programcode

@Programcode Programcode commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Round 5 of iOS parity: speed and native polish. Stacked on #266 (base native-ios/parity-r3r4-combined). Nothing on the server changes, and there was no EAS build.

What changed

Long lists draw only what is on screen. History, the Library (phone, and the iPad list pane) and saved chats (the iPad pane and the phone sheet) are now Reanimated FlatLists through one ListScreen component. Each row draws its part of the card. Rows still slide into a deleted row's gap. A row fades up when it arrives in the list, not every time it scrolls back into view (row-arrivals.ts, tested). Shimmer placeholders, empty states and Reduce Motion behave as before. Pull to refresh is new on History and the Library.

I used FlatList, not FlashList. FlashList recycles cells, and these rows hold swipe, press and link-preview state that would leak between items. Chat transcripts stay scroll views. Crossfire and Sparring rounds are bounded by their format. Ask's messages are memoized instead, because a chat is tens of messages and a virtualized chat with scroll-to-end and the keyboard couldn't be checked without a device.

Re-renders. I ran React Compiler's Babel plugin with a logger, and it was skipping Ask, Crossfire and Sparring entirely. The causes were lint suppressions, ref.current written during render, and the ⌘↩ hook calling a function declared below it.

  • Ask keeps the streamed answer in a live value (lib/live-value.ts) that only the answer being written reads, and its saved messages are memoized. A token or a keystroke no longer redraws the conversation. Stop, the 30 ms batching and scroll-to-end work as before.
  • Crossfire compiles again. Its clock ticks in its own component, which also ends the round at time up.
  • Sparring's blockers are fixed except try/catch shapes that compiler 1.0 can't lower. Its live round component compiles.
  • Ask stays uncompiled for the same reason, because its request code uses try/finally. Rewriting those request flows wasn't worth the risk.

iPad keyboard shortcuts. ⌘N opens a new debate, ⌘K the switcher, and ⌘↩ sends in Ask, Crossfire and Sparring. Each has a title in the ⌘ overlay. One registry decides which screen gets a press (tested), and screens listen only while in view. The native side is the app's own Expo module, apps/mobile/modules/key-commands, about 100 lines of Swift that add UIKeyCommands to UIApplication near the end of the responder chain. No maintained package fit SDK 57, and Expensify's needs Objective-C AppDelegate edits. Autolinking finds the module. The Swift has not been compiled.

Quick actions and Spotlight now work, with expo-quick-actions 6 and expo-core-spotlight 2 (3.0 dropped the config plugin). Spotlight indexes the Current Debate's cases, attacks, brainstorms and factoids, where it used to index cases only. The index empties when the Current Debate changes, on sign-out and after Delete all my data (spotlight-items.ts and spotlight.ts, tested).

Share extension (expo-share-intent 8). Sharing a page from Safari opens Add link and starts the import. Sharing text opens New note filled in (share-intent-parse.ts, tested). The sheets get shared content only through a one-shot in-memory hand-off, so a debatelab://add-link?url=… link from anywhere else can't start a signed-in import. Only http and https addresses are accepted.

VoiceOver. Every list row's long-press menu is also its accessibility actions. That covers saved work (Discuss, Share, Delete), History, Library sources (Copy and Share beside the switch and Delete) and recent sources.

Review

Five Codex rounds (gpt-6.1-sol, xhigh). I checked each finding against the code, and all were real. Round 5 had no findings on eb0af2ce.

  • Round 1, high. A crafted debatelab://dataUrl= link without a #type crashed the app, because expo-share-intent's reader force-unwraps the fragment. Now only the extension's exact URL (our key, a UUID nonce, #text or #weburl) reaches the module. Any other share-shaped link opens the Debate tab.
  • Round 1, medium. A share read under one account could open under the next. Each item now belongs to the account its read started under and is dropped if that account signs out first.
  • Round 1, medium. A Spotlight clear could be undone by indexing already in flight. All Spotlight calls now run in one queue, and a clear cancels indexing asked for before it. A new test fails against the old code.
  • Round 1, medium. A History "Add to debate" row lost its chosen debate when it scrolled out of the render window, and an attach that finished meanwhile was ignored. That state now lives in the History screen.
  • Round 2, medium. Two share reads in flight across an account switch could hand the first account's item to the second. Reads now run one at a time.
  • Round 2, medium. An attach finishing after a switch to another debate's History discarded that debate's load and left skeleton rows. The attach now changes the load counter only while the unsorted list is on screen.
  • Round 3, medium. An attach finishing after History went to a debate and back to the unsorted list, before that list loaded, dropped its load. The attach now reads the unsorted list again in that case.
  • Round 3, medium. Spotlight read the Debate tab's side lists, which leave out neutral work (every attack and factoids set) and hold three to five items. It now reads the debate's newest 40 documents of every side, and only in builds that have Spotlight.
  • Round 4, medium. Retry or the reload after a delete on a Debate tab that had since unmounted could index the previous debate over the new one. Indexing now also requires the tab to still be mounted.

Two more fixes came from my own second look. The streamed Ask answer could vanish for one frame before the finished one replaced it. The phase was read through useSyncExternalStore, whose renders run ahead of ordinary state updates, so it is plain state again. The share URL now waits in share-inbox.ts, which imports nothing, so +native-intent.tsx stays as light as deep-links.ts.

What needs a development build or Adam's Apple account

  • Keyboard shortcuts, quick actions, Spotlight and the share extension need a development build. Expo Go is unchanged, and npx expo config --json is byte-identical before and after.
  • The share extension needs the App Group group.com.pistack.debateowl registered on Adam's Apple account for com.pistack.debateowl.share-extension, as well as for the app and ExpoWidgetsTarget. Build day's credentials.json now lists three targets. Its main key used to be DebateLab, but prebuild names the target DebateOwl, so docs/development.md now says DebateOwl.

Checks

  • npm run verify passed locally at eb0af2ce. That run covers the docs check, eslint, knip and the repo rules, typecheck including the mobile app, 908 unit tests, 30 collaboration tests, 553 Edge Function tests, the web build, the service-worker check and 47 Playwright smoke tests. It is a local pass, not CI, because GitHub Actions is out of minutes.
  • DEBATELAB_BUILD=dev DEBATELAB_APP_GROUP=… npx expo prebuild --platform ios --no-install produced the DebateOwlShare target with its App Group entitlement, the Spotlight AppDelegate hook, the quick-action Info.plist items and no push entitlement. I deleted the ios/ folder afterwards.
  • Nothing ran on a device or simulator, and there are no web-export screenshots, because expo export runs out of memory on this VPS. The list layouts, the ⌘ overlay and the share sheet still need to be seen in the app.

Risks

  • The key-command Swift is uncompiled. If ⌘ shortcuts don't fire while no text field has focus, the fallback is an AppDelegate keyCommands override added through a config plugin.
  • EAS signing with three extension targets is untested.
  • Share ownership assumes expo-share-intent delivers a read's event before that read's promise resolves, which its Swift does. Two shares straddling an account switch inside that window remain a theoretical edge case.

🤖 Generated with Claude Code

Programcode and others added 7 commits October 29, 2026 10:55
⌘N, ⌘K and ⌘↩ now reach the app in a development build through
modules/key-commands, with titles in the ⌘ overlay. One registry decides
which screen a press belongs to, and screens listen only while in view.
Installs expo-quick-actions, expo-core-spotlight 2.x and expo-share-intent
so the round-4 quick actions and Spotlight switch on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ment

The share extension (expo-share-intent, development builds with the App
Group) takes a web page into Add link, which starts the import, and other
text into New note. Spotlight indexes the Current Debate's cases, attacks,
brainstorms and factoids, and forgets them when the Current Debate changes,
on sign-out and after Delete all my data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each is a Reanimated FlatList (ListScreen) with the card drawn per row, so
rows still slide into a deleted row's gap. A row fades up when it arrives
in the list, not every time it scrolls back into view (row-arrivals.ts).

A shared page or note reaches its sheet only through the share hand-off,
so a debatelab://add-link?url= link from elsewhere cannot start an import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
React Compiler was skipping Ask, Crossfire and Sparring, so every token
and clock tick re-rendered the whole screen. Ask's streamed text is now a
live value that only the answer being written reads, and its saved
messages are memoized; Crossfire's clock ticks in its own component and
the screen compiles again. Refs written during render and hooks used
before their functions are declared are gone from all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saved work (Discuss, Share, Delete), History rows (Discuss and Share beside
Delete), Library sources (Copy and Share beside the switch and Delete) and
the Debate tab's recent sources (Copy, Share) offer their menu as
accessibility actions, so a VoiceOver user swipes up or down instead of
needing the long press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Build day now signs the share extension too, and the credentials keys use
the Xcode target names prebuild writes (DebateOwl, not DebateLab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

Programcode and others added 5 commits October 29, 2026 11:29
The streaming phase came from an external store, whose updates render on
their own before the finished message lands. It is plain state again, so
both change in one render, and ending a stream leaves its text in place
until the next one starts.

The share extension's URL now waits in share-inbox.ts, which imports
nothing, so +native-intent.tsx stays as light as deep-links.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Only the share extension's own URL (key, UUID nonce, #text or #weburl)
  reaches expo-share-intent, whose reader force-unwraps the fragment: a
  crafted debatelab://dataUrl= link opens the Debate tab instead of
  crashing the app.
- A shared item belongs to the account it was read under; if that
  account signs out first it is dropped, never opened for the next one.
- Spotlight calls run in one queue and a clear cancels indexing asked for
  before it, so a signed-out account's documents cannot be indexed again.
- History's Add to debate keeps the debate picked and the request in
  flight in the screen, so a row scrolled away and back loses neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r History

- Share reads run one after another, and the account a read started under
  stays its owner until the next read, so two reads across an account
  switch cannot hand the first account's item to the second.
- Adding unsorted work to a debate drops History's running load only while
  the unsorted list is on screen; after switching to a debate's History,
  that debate's load still lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oads after a round trip

- Spotlight read the Debate tab's side lists, which leave out neutral work
  (every attack and factoids set) and hold three to five items. It now
  reads the debate's newest 40 documents of every side, only in builds
  that have Spotlight.
- An attach that finishes after History went to a debate and back to the
  unsorted list, before that list loaded, reads the list again instead of
  dropping the load running for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…light

Retry and the reload after a delete run without the focus guard, and an
unmounted dashboard keeps its old Current Debate, so a late document read
could index debate A over B. Indexing now also requires the dashboard to
still be mounted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Programcode
Programcode changed the base branch from native-ios/parity-r3r4-combined to feat/native-ios October 29, 2026 18:57
@Programcode
Programcode marked this pull request as ready for review October 29, 2026 18:58
Copilot AI balanced review requested due to automatic review settings October 29, 2026 18:58
@Programcode
Programcode merged commit 5b6c4f9 into feat/native-ios Oct 29, 2026
3 of 11 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

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