diff --git a/packages/eclipsa/core/action.test.ts b/packages/eclipsa/core/action.test.ts index 156eef3..d365a7c 100644 --- a/packages/eclipsa/core/action.test.ts +++ b/packages/eclipsa/core/action.test.ts @@ -442,6 +442,85 @@ describe('action runtime', () => { } }) + it('ignores stale out-of-order submissions on client handles', async () => { + const container = createRuntimeContainer() + const originalFetch = globalThis.fetch + const resolvers: Array<(value: string) => void> = [] + globalThis.fetch = vi.fn( + () => + new Promise((resolve) => { + resolvers.push((value) => { + resolve( + new Response(JSON.stringify({ ok: true, value }), { + headers: { 'content-type': 'application/json' }, + }), + ) + }) + }), + ) as typeof fetch + + try { + const useEcho = __eclipsaAction('stale-handle', [], async (input: string) => input) + const handle = withRuntimeContainer(container, () => useEcho()) + + const first = handle.action('first') + const second = handle.action('second') + expect(resolvers).toHaveLength(2) + + resolvers[1]!('second') + await expect(second).resolves.toBe('second') + expect(handle.result).toBe('second') + expect(handle.lastSubmission).toEqual({ error: undefined, input: 'second', result: 'second' }) + expect(handle.isPending).toBe(false) + + resolvers[0]!('first') + await expect(first).resolves.toBe('first') + expect(handle.result).toBe('second') + expect(handle.lastSubmission).toEqual({ error: undefined, input: 'second', result: 'second' }) + expect(handle.isPending).toBe(false) + } finally { + globalThis.fetch = originalFetch + } + }) + + it('keeps pending while a newer submission is still in flight', async () => { + const container = createRuntimeContainer() + const originalFetch = globalThis.fetch + const resolvers: Array<(value: string) => void> = [] + globalThis.fetch = vi.fn( + () => + new Promise((resolve) => { + resolvers.push((value) => { + resolve( + new Response(JSON.stringify({ ok: true, value }), { + headers: { 'content-type': 'application/json' }, + }), + ) + }) + }), + ) as typeof fetch + + try { + const useEcho = __eclipsaAction('pending-handle', [], async (input: string) => input) + const handle = withRuntimeContainer(container, () => useEcho()) + + const first = handle.action('first') + const second = handle.action('second') + + resolvers[0]!('first') + await expect(first).resolves.toBe('first') + expect(handle.isPending).toBe(true) + expect(handle.result).toBeUndefined() + + resolvers[1]!('second') + await expect(second).resolves.toBe('second') + expect(handle.isPending).toBe(false) + expect(handle.result).toBe('second') + } finally { + globalThis.fetch = originalFetch + } + }) + it('uses absolute action RPC URLs inside a server request context', async () => { const fetchImpl = vi.fn( async () => diff --git a/packages/eclipsa/core/action.ts b/packages/eclipsa/core/action.ts index 25b0a5b..27ed1b4 100644 --- a/packages/eclipsa/core/action.ts +++ b/packages/eclipsa/core/action.ts @@ -1233,10 +1233,16 @@ export const __eclipsaAction = < await flushDirtyComponents(container) } + let latestSubmissionId = 0 + const applySubmissionChunk = async ( + submissionId: number, input: ActionInput, value: ActionStreamYield, ) => { + if (submissionId !== latestSubmissionId) { + return + } result.value = value lastSubmission.value = { error: undefined, @@ -1247,7 +1253,14 @@ export const __eclipsaAction = < await flushHandleUpdates() } - const applySubmissionError = async (input: ActionInput, caught: unknown) => { + const applySubmissionError = async ( + submissionId: number, + input: ActionInput, + caught: unknown, + ) => { + if (submissionId !== latestSubmissionId) { + return + } error.value = caught lastSubmission.value = { error: caught, @@ -1258,12 +1271,16 @@ export const __eclipsaAction = < await flushHandleUpdates() } - const finalizeSubmission = async () => { + const finalizeSubmission = async (submissionId: number) => { + if (submissionId !== latestSubmissionId) { + return + } pending.value = false await flushHandleUpdates() } const invoke = (input: ActionInput | FormData) => { + const submissionId = ++latestSubmissionId pending.value = true error.value = undefined const normalizedInput = normalizeFormSubmissionInput(input) as ActionInput @@ -1276,19 +1293,19 @@ export const __eclipsaAction = < if (isAsyncGeneratorValue(value)) { for await (const chunk of value) { const resolved = chunk as ActionStreamYield - await applySubmissionChunk(normalizedInput, resolved) + await applySubmissionChunk(submissionId, normalizedInput, resolved) yield resolved } return } const resolved = value as ActionStreamYield - await applySubmissionChunk(normalizedInput, resolved) + await applySubmissionChunk(submissionId, normalizedInput, resolved) yield resolved } catch (caught) { - await applySubmissionError(normalizedInput, caught) + await applySubmissionError(submissionId, normalizedInput, caught) throw caught } finally { - await finalizeSubmission() + await finalizeSubmission(submissionId) } })() as ActionInvokeResult } @@ -1296,14 +1313,14 @@ export const __eclipsaAction = < return request .then(async (value) => { const resolved = value as ActionStreamYield - await applySubmissionChunk(normalizedInput, resolved) + await applySubmissionChunk(submissionId, normalizedInput, resolved) return value as ResolvedActionOutput }) .catch(async (caught) => { - await applySubmissionError(normalizedInput, caught) + await applySubmissionError(submissionId, normalizedInput, caught) throw caught }) - .finally(finalizeSubmission) as ActionInvokeResult + .finally(() => finalizeSubmission(submissionId)) as ActionInvokeResult } const Form = (props: ActionFormProps) => {