Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Evaluate and merge
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

Expand Down Expand Up @@ -46,7 +46,7 @@ jobs:
run: git-cliff --config cliff.toml --output CHANGELOG.md

- name: Commit and push CHANGELOG.md
uses: stefanzweifel/git-auto-commit-action@v5
uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5
with:
file_pattern: CHANGELOG.md
commit_message: "chore(changelog): update changelog [skip ci]"
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/contract-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Install Rust stable toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable
with:
components: clippy
targets: wasm32-unknown-unknown

- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
with:
workspaces: Dechat/stellar-contracts -> target

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable
with:
toolchain: stable
components: clippy
targets: wasm32-unknown-unknown

- name: Cache Cargo registry
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/deploy-futurenet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
Expand All @@ -38,7 +38,7 @@ jobs:
soroban --version

- name: Cache Cargo registry
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
Expand Down Expand Up @@ -68,15 +68,15 @@ jobs:

- name: Upload contract ID artifact
if: success()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: contract-deployment-${{ github.sha }}
path: Dechat/stellar-contracts/contract_id_futurenet.txt
retention-days: 90

- name: Create deployment status
if: always()
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const status = '${{ job.status }}' === 'success' ? 'success' : 'failure';
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/frontend-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Install pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
with:
version: 8

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
cache: "pnpm"
Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Install pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
with:
version: 8

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: "pnpm"
Expand Down Expand Up @@ -96,15 +96,15 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Install pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
with:
version: 8

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: "pnpm"
Expand Down Expand Up @@ -136,7 +136,7 @@ jobs:
NEXT_PUBLIC_STELLAR_NETWORK: ${{ secrets.NEXT_PUBLIC_STELLAR_NETWORK || 'TESTNET' }}

- name: Upload Playwright report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: playwright-report
Expand Down
84 changes: 84 additions & 0 deletions Dechat/dex_with_fiat_frontend/src/hooks/useChatHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,53 @@ describe('Thread pinning ordering', () => {
});
});

// ---------------------------------------------------------------------------
// Race-condition regression tests (#1213)
// ---------------------------------------------------------------------------
//
// These tests cover the two stale-closure bugs that were fixed:
//
// 1. updateCurrentSession — previously read `historyState.currentSessionId`
// from the outer closure. If the session changed between renders the guard
// (`if (!historyState.currentSessionId) return`) would be stale and could
// either block a valid update or allow an update targeting the wrong session.
// Fix: moved the guard inside the functional updater so it always sees the
// latest committed state.
//
// 2. loadSession — previously read `historyState.sessions` from the closure,
// which could be a snapshot from a previous render. Any session created
// after the closure was captured would be invisible to the lookup.
// Fix: reads from `sessionsRef.current` which is kept current via a
// synchronous ref-update effect.
//
// Because these are pure-logic tests (no React rendering required) they
// replicate the core logic inline and verify the corrected behaviour.

describe('Race-condition fix: updateCurrentSession functional updater (#1213)', () => {
it('update is a no-op when currentSessionId is null in latest state', () => {
type State = { currentSessionId: string | null; sessions: { id: string; messages: string[] }[] };

// Simulate the fixed functional updater
const updater = (messages: string[]) => (prev: State): State => {
if (!prev.currentSessionId) return prev;
const idx = prev.sessions.findIndex((s) => s.id === prev.currentSessionId);
if (idx === -1) return prev;
const updated = [...prev.sessions];
updated[idx] = { ...updated[idx], messages };
return { ...prev, sessions: updated };
};

const state: State = { currentSessionId: null, sessions: [{ id: 'a', messages: [] }] };
const next = updater(['msg'])(state);

// No change because currentSessionId was null at update time
expect(next).toBe(state);
});

it('update targets the correct session even when currentSessionId changed before dispatch', () => {
type State = { currentSessionId: string | null; sessions: { id: string; messages: string[] }[] };

const updater = (messages: string[]) => (prev: State): State => {
// ── updateCurrentSession stale-closure regression (#1223) ──────────────────
//
// Before the fix, updateCurrentSession closed over historyState.currentSessionId
Expand All @@ -132,6 +179,43 @@ describe('updateCurrentSession guard reads fresh state (regression #1223)', () =
updated[idx] = { ...updated[idx], messages };
return { ...prev, sessions: updated };
};

// State has switched to session 'b' by the time the updater runs
const state: State = {
currentSessionId: 'b',
sessions: [
{ id: 'a', messages: [] },
{ id: 'b', messages: [] },
],
};

const next = updater(['hello'])(state);

expect(next.sessions.find((s) => s.id === 'b')?.messages).toEqual(['hello']);
expect(next.sessions.find((s) => s.id === 'a')?.messages).toEqual([]);
});
});

describe('Race-condition fix: loadSession uses sessionsRef (#1213)', () => {
it('lookup finds a session added after the callback was captured', () => {
// Simulate sessionsRef — always points to latest sessions array
const sessionsRef = { current: [] as { id: string; messages: string[] }[] };

// Simulate the fixed loadSession using sessionsRef
const loadSession = (sessionId: string) => {
return sessionsRef.current.find((s) => s.id === sessionId) ?? null;
};

// Callback captured here with empty sessions
expect(loadSession('new')).toBeNull();

// Session added later — ref is updated synchronously (as the useEffect does)
sessionsRef.current = [{ id: 'new', messages: ['hi'] }];

// Now loadSession finds it, despite being "captured" before it existed
const found = loadSession('new');
expect(found).not.toBeNull();
expect(found?.messages).toEqual(['hi']);
}

it('updates the session identified by prev.currentSessionId, not a stale outer value', () => {
Expand Down
48 changes: 32 additions & 16 deletions Dechat/dex_with_fiat_frontend/src/hooks/useChatHistory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { ChatSession, ChatHistoryState, ChatMessage } from '@/types';
import { ChatHistoryManager } from '@/lib/chatHistory';
import { useStellarWallet } from '@/contexts/StellarWalletContext';
Expand All @@ -15,6 +15,13 @@ export const useChatHistory = () => {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<ChatSession[]>([]);

// Keep a ref to the latest sessions so callbacks that need to read (not write)
// sessions never capture a stale closure snapshot.
const sessionsRef = useRef<ChatSession[]>(historyState.sessions);
useEffect(() => {
sessionsRef.current = historyState.sessions;
}, [historyState.sessions]);

// Load history from localStorage on mount
useEffect(() => {
const loaded = ChatHistoryManager.loadFromLocalStorage();
Expand Down Expand Up @@ -76,6 +83,13 @@ export const useChatHistory = () => {

const updateCurrentSession = useCallback(
(messages: ChatMessage[]) => {
// Race-condition fix (#1213): the previous implementation read
// `historyState.currentSessionId` from the closure, which could be stale
// if multiple state updates were in-flight. The early-return guard has
// been moved inside the functional updater so it always sees the latest
// committed state — no stale snapshot can cause a phantom update or a
// missed guard.
setHistoryState((prev) => {
setHistoryState((prev) => {
// Guard inside the functional updater so it always reads fresh state,
// not the stale closure value of historyState.currentSessionId (#1223).
Expand Down Expand Up @@ -109,7 +123,12 @@ export const useChatHistory = () => {

const loadSession = useCallback(
(sessionId: string): ChatMessage[] | null => {
const session = historyState.sessions.find((s) => s.id === sessionId);
// Race-condition fix (#1213): reading `historyState.sessions` from the
// closure captured at useCallback creation could be a stale snapshot when
// rapid session switches occur. We now read from sessionsRef which is
// synchronously updated via a layout-effect-ordered ref, ensuring the
// lookup always reflects the latest committed sessions list.
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (!session) return null;

setHistoryState((prev) => ({
Expand All @@ -119,7 +138,7 @@ export const useChatHistory = () => {

return session.messages;
},
[historyState.sessions],
[],
);

const deleteSession = useCallback((sessionId: string) => {
Expand All @@ -145,53 +164,50 @@ export const useChatHistory = () => {

const exportSession = useCallback(
(sessionId: string): string | null => {
const session = historyState.sessions.find((s) => s.id === sessionId);
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (!session) return null;

return ChatHistoryManager.exportSession(session);
},
[historyState.sessions],
[],
);

const exportSessionAsJSON = useCallback(
(sessionId: string): { data: string; filename: string } | null => {
const session = historyState.sessions.find((s) => s.id === sessionId);
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (!session) return null;

const data = ChatHistoryManager.exportSessionAsJSON(session);
const filename = ChatHistoryManager.generateExportFilename(sessionId, 'json');
return { data, filename };
},
[historyState.sessions],
[],
);

const exportSessionAsTXT = useCallback(
(sessionId: string): { data: string; filename: string } | null => {
const session = historyState.sessions.find((s) => s.id === sessionId);
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (!session) return null;

const data = ChatHistoryManager.exportSessionAsTXT(session);
const filename = ChatHistoryManager.generateExportFilename(sessionId, 'txt');
return { data, filename };
},
[historyState.sessions],
[],
);

const searchSessions = useCallback(
(query: string): ChatSession[] => {
return ChatHistoryManager.searchSessions(historyState.sessions, query);
return ChatHistoryManager.searchSessions(sessionsRef.current, query);
},
[historyState.sessions],
[],
);

const getCurrentSession = useCallback((): ChatSession | null => {
if (!historyState.currentSessionId) return null;
return (
historyState.sessions.find(
sessionsRef.current.find(
(s) => s.id === historyState.currentSessionId,
) || null
);
}, [historyState.currentSessionId, historyState.sessions]);
}, [historyState.currentSessionId]);

const togglePin = useCallback((sessionId: string) => {
setHistoryState((prev) => {
Expand Down
Loading
Loading