From de5810293ec0e8800421c47408c06c1ede1ec99d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:39:07 +0000 Subject: [PATCH 1/2] Initial plan From 3c7bfe87c7754844356ff2438d562ebc42f02007 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:03:34 +0000 Subject: [PATCH 2/2] feat: offload reconstruction parsing to a background thread via react-native-worklets-core Agent-Logs-Url: https://github.com/SpeedcuberOSS/speedcuber-timer/sessions/570e2424-b1cb-435b-9388-f0897bcc9419 Co-authored-by: thehale <47901316+thehale@users.noreply.github.com> --- babel.config.js | 6 +- package.json | 1 + src/__mocks__/globalMock.js | 10 +- src/lib/reconstructions/index.ts | 2 + .../parseReconstructionAsync.test.ts | 67 +++++++++++++ src/lib/recordings/parseReconstruction.ts | 1 + .../recordings/parseReconstructionAsync.ts | 46 +++++++++ src/lib/recordings/parseTimestampedMoves.ts | 2 + src/ui/components/PracticeView.tsx | 96 +++++++++++-------- yarn.lock | 7 ++ 10 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 src/lib/recordings/__tests__/parseReconstructionAsync.test.ts create mode 100644 src/lib/recordings/parseReconstructionAsync.ts diff --git a/babel.config.js b/babel.config.js index 8f68066..34b25d8 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,10 @@ module.exports = { presets: ['@rnx-kit/babel-preset-metro-react-native'], - plugins: ['react-native-paper/babel', 'react-native-reanimated/plugin'], + plugins: [ + 'react-native-paper/babel', + 'react-native-worklets-core/plugin', + 'react-native-reanimated/plugin', + ], env: { production: { plugins: ['transform-remove-console'], diff --git a/package.json b/package.json index 88627bf..4b5028e 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "react-native-svg": "^13.9.0", "react-native-vector-icons": "^9.1.0", "react-native-webview": "^12.0.2", + "react-native-worklets-core": "1.6.3", "realm": "^12.2.1", "solution-analyzer": "^0.1.6", "uuid": "^9.0.0" diff --git a/src/__mocks__/globalMock.js b/src/__mocks__/globalMock.js index ebdbcd1..355af18 100644 --- a/src/__mocks__/globalMock.js +++ b/src/__mocks__/globalMock.js @@ -4,6 +4,14 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +jest.mock('react-native-worklets-core', () => ({ + createWorkletContext: () => ({ + runAsync: fn => Promise.resolve(fn()), + runSync: fn => fn(), + }), + runOnJS: fn => fn, +})); + jest.mock('react-native-webview', () => { const { View } = require('react-native'); return { @@ -26,4 +34,4 @@ jest.mock('react-native-fs', () => ({ unlink: jest.fn(), writeFile: jest.fn(), })); -export const fs = jest.mocked(actualfs); \ No newline at end of file +export const fs = jest.mocked(actualfs); diff --git a/src/lib/reconstructions/index.ts b/src/lib/reconstructions/index.ts index 52c3211..2b561a2 100644 --- a/src/lib/reconstructions/index.ts +++ b/src/lib/reconstructions/index.ts @@ -13,6 +13,7 @@ export default function reconstructionFor( scramble: STIF.Algorithm, moves: STIF.TimestampedMove[], ) { + 'worklet'; return { using: (method: SolutionMethod) => __reconstructionFor(scramble, moves, method), @@ -24,6 +25,7 @@ function __reconstructionFor( moves: STIF.TimestampedMove[], method: SolutionMethod, ) { + 'worklet'; const breakdown = analyzeSolution( scramble.join(' '), moves.map(v => v.m).join(' '), diff --git a/src/lib/recordings/__tests__/parseReconstructionAsync.test.ts b/src/lib/recordings/__tests__/parseReconstructionAsync.test.ts new file mode 100644 index 0000000..aa70a12 --- /dev/null +++ b/src/lib/recordings/__tests__/parseReconstructionAsync.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2023 Joseph Hale +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import { STIF } from '../../stif'; +import { parseReconstruction } from '../parseReconstruction'; +import { parseReconstructionAsync } from '../parseReconstructionAsync'; + +const rcAttempt = + require('../__fixtures__/rubiks_connected_attempt.json') as STIF.Attempt; +const rcRecording = + require('../__fixtures__/rubiks_connected_recording.json') as STIF.SolveRecording; + +describe('parseReconstructionAsync', () => { + it('resolves with the same result as parseReconstruction for a Rubiks Connected attempt', async () => { + const attempt = rcAttempt; + const scramble = attempt.solutions[0].scramble; + const expected = parseReconstruction(rcRecording, scramble, attempt.timerStart); + const actual = await parseReconstructionAsync( + rcRecording, + scramble, + attempt.timerStart, + ); + expect(actual).toEqual(expected); + }); + + it('resolves with the same result as parseReconstruction for a Particula 2x2x2', async () => { + const attempt = + require('../__fixtures__/particula_2x2x2_attempt.json') as STIF.Attempt; + const recording = + require('../__fixtures__/particula_2x2x2_recording.json') as STIF.SolveRecording; + const scramble = attempt.solutions[0].scramble; + const expected = parseReconstruction(recording, scramble, attempt.timerStart); + const actual = await parseReconstructionAsync( + recording, + scramble, + attempt.timerStart, + ); + expect(actual).toEqual(expected); + }); + + it('resolves with the same result as parseReconstruction for a Particula 3x3x3', async () => { + const attempt = + require('../__fixtures__/particula_3x3x3_attempt.json') as STIF.Attempt; + const recording = + require('../__fixtures__/particula_3x3x3_recording.json') as STIF.SolveRecording; + const scramble = attempt.solutions[0].scramble; + const expected = parseReconstruction(recording, scramble, attempt.timerStart); + const actual = await parseReconstructionAsync( + recording, + scramble, + attempt.timerStart, + ); + expect(actual).toEqual(expected); + }); + + it('resolves with an empty array when there is no message stream', async () => { + const actual = await parseReconstructionAsync( + { smartPuzzle: rcRecording.smartPuzzle, stream: [] }, + rcAttempt.solutions[0].scramble, + rcAttempt.timerStart, + ); + expect(actual).toEqual([]); + }); +}); diff --git a/src/lib/recordings/parseReconstruction.ts b/src/lib/recordings/parseReconstruction.ts index 5921c35..fd0e2ef 100644 --- a/src/lib/recordings/parseReconstruction.ts +++ b/src/lib/recordings/parseReconstruction.ts @@ -17,6 +17,7 @@ export function parseReconstruction( startTime: number = 0, method: SolutionMethod = 'CFOP', ) { + 'worklet'; const moves = parseTimestampedMoves(recording, startTime); const rawReconstruction = reconstructionFor(scramble, moves).using(method); const reconstruction = rawReconstruction.map(phase => { diff --git a/src/lib/recordings/parseReconstructionAsync.ts b/src/lib/recordings/parseReconstructionAsync.ts new file mode 100644 index 0000000..876a0f4 --- /dev/null +++ b/src/lib/recordings/parseReconstructionAsync.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2023 Joseph Hale +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import { createWorkletContext, runOnJS } from 'react-native-worklets-core'; + +import { SolutionMethod } from '../reconstructions'; +import { STIF } from '../stif'; +import { parseReconstruction } from './parseReconstruction'; + +const ReconstructionContext = createWorkletContext('ReconstructionContext'); + +export type ReconstructionPhase = ReturnType< + typeof parseReconstruction +>[number]; + +/** + * Parses the BLE event stream into a reconstruction asynchronously on a + * dedicated background thread, keeping the UI responsive while the + * CPU-intensive solution analysis runs. + */ +export function parseReconstructionAsync( + recording: STIF.SolveRecording, + scramble: STIF.Algorithm, + startTime: number = 0, + method: SolutionMethod = 'CFOP', +): Promise { + return new Promise((resolve, reject) => { + ReconstructionContext.runAsync(() => { + 'worklet'; + try { + const result = parseReconstruction( + recording, + scramble, + startTime, + method, + ); + runOnJS(resolve)(result); + } catch (e) { + runOnJS(reject)(e); + } + }); + }); +} diff --git a/src/lib/recordings/parseTimestampedMoves.ts b/src/lib/recordings/parseTimestampedMoves.ts index d479783..842570c 100644 --- a/src/lib/recordings/parseTimestampedMoves.ts +++ b/src/lib/recordings/parseTimestampedMoves.ts @@ -15,6 +15,7 @@ export function parseTimestampedMoves( recording: STIF.SolveRecording, startTime: number = 0, ): STIF.TimestampedMove[] { + 'worklet'; return adjustTimestamps(parseMoves(recording)) .relativeTo(startTime) .sort((a, b) => a.t - b.t); @@ -30,6 +31,7 @@ export function parseTimestampedMoves( export function compressDoubleTurns( moves: STIF.TimestampedMove[], ): STIF.TimestampedMove[] { + 'worklet'; const window = moves[moves.length - 1].t / moves.length; const movesMatch = (m1: string | null, m2: string | null) => diff --git a/src/ui/components/PracticeView.tsx b/src/ui/components/PracticeView.tsx index 2c483ea..6d8a1d9 100644 --- a/src/ui/components/PracticeView.tsx +++ b/src/ui/components/PracticeView.tsx @@ -17,7 +17,7 @@ import { useAttemptCreator, useSolveRecordingCreator, } from '../../persistence/hooks'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Attempt } from '../../lib/stif/wrappers'; import { GeneratedScramble } from './scrambles/types'; @@ -25,7 +25,7 @@ import InspectionTimer from './inspection/InspectionTimer'; import { STIF } from '../../lib/stif'; import ScramblingView from './scrambles/ScramblingView'; import SolveTimer from './SolveTimer'; -import { parseReconstruction } from '../../lib/recordings/parseReconstruction'; +import { parseReconstructionAsync } from '../../lib/recordings/parseReconstructionAsync'; import { useCompetitiveEvent } from '../hooks/useCompetitiveEvent'; enum TimerState { @@ -109,58 +109,76 @@ export default function PracticeView() { if (didNotStart) { console.log('DNF Detected'); // TODO Ensure DNFs are handled correctly. - const attempt = assembleAttempt(); - persistAttempt(attempt); - setTimerState(TimerState.SCRAMBLING); + handleSolveComplete(); } else { nextTimerState(); } } - function handleSolveComplete() { - const attempt = assembleAttempt(); - persistAttempt(attempt); - nextTimerState(); - } - - function assembleAttempt() { + const handleSolveComplete = useCallback(() => { const now = new Date().getTime(); - const didNotStart = timerStart < inspectionStart; - const attempt = new AttemptBuilder() - .setEvent(event) - .setInspectionStart(inspectionStart) - .setTimerStart(didNotStart ? now : timerStart) + + // Capture state before transitioning so async code uses the right snapshot. + const capturedWipSolutions = wipSolutions; + const capturedInspectionStart = inspectionStart; + const capturedTimerStart = timerStart; + const capturedEvent = event; + + // Stop BLE subscriptions and snapshot the recordings synchronously so + // no new messages are added after the solve ends. + const capturedRecordings = capturedWipSolutions.map(wip => { + if (wip.messageSubscription) { + wip.messageSubscription.remove(); + } + return wip.messages?.build(); + }); + + // Transition the UI immediately so the user sees no lag. + setTimerState(TimerState.SCRAMBLING); + + // Build and persist the attempt in the background. + const didNotStart = capturedTimerStart < capturedInspectionStart; + const attemptBuilder = new AttemptBuilder() + .setEvent(capturedEvent) + .setInspectionStart(capturedInspectionStart) + .setTimerStart(didNotStart ? now : capturedTimerStart) .setTimerStop(now); - wipSolutions - .map(wip => { - const recording = wip.messages?.build(); + + Promise.all( + capturedWipSolutions.map(async (wip, idx) => { + const recording = capturedRecordings[idx]; if (recording) { - const reconstruction = parseReconstruction( + const reconstruction = await parseReconstructionAsync( recording, wip.scramble.algorithm, - timerStart, + capturedTimerStart, ); reconstruction.forEach(phase => wip.builder.addSolutionPhase(phase)); } - if (wip.messageSubscription) { - wip.messageSubscription.remove(); - } return wip.builder.build(); + }), + ) + .then(solutions => { + solutions.forEach(solution => attemptBuilder.addSolution(solution)); + const attempt = attemptBuilder.build(); + createAttempt(attempt); + setLastAttempt(new Attempt(attempt)); + capturedWipSolutions.forEach((wip, idx) => { + const recording = capturedRecordings[idx]; + if (recording) { + createRecording(attempt.solutions[idx].id, recording); + } + }); }) - .forEach(solution => attempt.addSolution(solution)); - return attempt.build(); - } - - function persistAttempt(attempt: STIF.Attempt) { - createAttempt(attempt); - setLastAttempt(new Attempt(attempt)); - wipSolutions.map((wip, idx) => { - const recording = wip.messages?.build(); - if (recording) { - createRecording(attempt.solutions[idx].id, recording); - } - }); - } + .catch(e => console.error('Failed to assemble or persist attempt', e)); + }, [ + wipSolutions, + inspectionStart, + timerStart, + event, + createAttempt, + createRecording, + ]); return ( diff --git a/yarn.lock b/yarn.lock index 44726ce..1ea2c7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8485,6 +8485,13 @@ react-native-webview@^12.0.2: escape-string-regexp "2.0.0" invariant "2.2.4" +react-native-worklets-core@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/react-native-worklets-core/-/react-native-worklets-core-1.6.3.tgz#e95d879b28890bf27f019797aa3d8538532b4f4e" + integrity sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA== + dependencies: + string-hash-64 "^1.0.3" + react-native@>=0.68: version "0.72.5" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"