Skip to content
Open
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
79 changes: 79 additions & 0 deletions packages/eclipsa/core/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((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<Response>((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 () =>
Expand Down
35 changes: 26 additions & 9 deletions packages/eclipsa/core/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,10 +1233,16 @@ export const __eclipsaAction = <
await flushDirtyComponents(container)
}

let latestSubmissionId = 0

const applySubmissionChunk = async (
submissionId: number,
input: ActionInput<Middlewares>,
value: ActionStreamYield<Output>,
) => {
if (submissionId !== latestSubmissionId) {
return
}
result.value = value
lastSubmission.value = {
error: undefined,
Expand All @@ -1247,7 +1253,14 @@ export const __eclipsaAction = <
await flushHandleUpdates()
}

const applySubmissionError = async (input: ActionInput<Middlewares>, caught: unknown) => {
const applySubmissionError = async (
submissionId: number,
input: ActionInput<Middlewares>,
caught: unknown,
) => {
if (submissionId !== latestSubmissionId) {
return
}
error.value = caught
lastSubmission.value = {
error: caught,
Expand All @@ -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<Middlewares> | FormData) => {
const submissionId = ++latestSubmissionId
pending.value = true
error.value = undefined
const normalizedInput = normalizeFormSubmissionInput(input) as ActionInput<Middlewares>
Expand All @@ -1276,34 +1293,34 @@ export const __eclipsaAction = <
if (isAsyncGeneratorValue(value)) {
for await (const chunk of value) {
const resolved = chunk as ActionStreamYield<Output>
await applySubmissionChunk(normalizedInput, resolved)
await applySubmissionChunk(submissionId, normalizedInput, resolved)
yield resolved
}
return
}
const resolved = value as ActionStreamYield<Output>
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<Output>
}

return request
.then(async (value) => {
const resolved = value as ActionStreamYield<Output>
await applySubmissionChunk(normalizedInput, resolved)
await applySubmissionChunk(submissionId, normalizedInput, resolved)
return value as ResolvedActionOutput<Output>
})
.catch(async (caught) => {
await applySubmissionError(normalizedInput, caught)
await applySubmissionError(submissionId, normalizedInput, caught)
throw caught
})
.finally(finalizeSubmission) as ActionInvokeResult<Output>
.finally(() => finalizeSubmission(submissionId)) as ActionInvokeResult<Output>
}

const Form = (props: ActionFormProps) => {
Expand Down
Loading