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 .changeset/array-default-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@lucas-barake/effect-form": patch
---

default array schemas to `[]` in `getDefaultFromSchema`
default array schemas to `[]` in `getDefaultFromSchema`
2 changes: 1 addition & 1 deletion .changeset/asyncresult-accessors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@lucas-barake/effect-form": patch
---

use AsyncResult accessors instead of manual result inspection in display-error logic
use AsyncResult accessors instead of manual result inspection in display-error logic
5 changes: 5 additions & 0 deletions .changeset/atom-debounce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lucas-barake/effect-form": minor
---

use `Atom.debounce` for validation and auto-submit debouncing; `ParsedMode.debounce` now carries `Duration.Input` instead of milliseconds
2 changes: 1 addition & 1 deletion .changeset/atom-family-registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@lucas-barake/effect-form": minor
---

replace the internal weak-registry with `Atom.family`; `validationAtomsRegistry` and `fieldAtomsRegistry` are removed from the `FormAtoms` interface
replace the internal weak-registry with `Atom.family`; `validationAtomsRegistry` and `fieldAtomsRegistry` are removed from the `FormAtoms` interface
2 changes: 1 addition & 1 deletion .changeset/autosubmit-lost-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@lucas-barake/effect-form": patch
---

fix auto-submit silently dropping a change made during a follow-up submit
fix auto-submit silently dropping a change made during a follow-up submit
2 changes: 1 addition & 1 deletion .changeset/descriptive-uninit-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
"@lucas-barake/effect-form-solid": patch
---

descriptive errors when form atoms are used before initialization
descriptive errors when form atoms are used before initialization
2 changes: 1 addition & 1 deletion .changeset/failed-submit-not-recorded.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@lucas-barake/effect-form": patch
---

a failed `onSubmit` is no longer recorded as a successful submit (`lastSubmittedValues` only updates when `onSubmit` succeeds)
a failed `onSubmit` is no longer recorded as a successful submit (`lastSubmittedValues` only updates when `onSubmit` succeeds)
2 changes: 1 addition & 1 deletion .changeset/rooterror-built-form.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"@lucas-barake/effect-form-solid": patch
---

declare `rootError` on `BuiltForm` in the react and solid bindings
declare `rootError` on `BuiltForm` in the react and solid bindings
2 changes: 1 addition & 1 deletion .changeset/simplify-builder-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@lucas-barake/effect-form": patch
---

flatten builder field intersections for readable hover types
flatten builder field intersections for readable hover types
2 changes: 1 addition & 1 deletion .changeset/solid-array-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@lucas-barake/effect-form-solid": patch
---

fix array item inputs losing focus and dropping input after one keystroke
fix array item inputs losing focus and dropping input after one keystroke
185 changes: 98 additions & 87 deletions packages/form/src/FormAtoms.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Duration from "effect/Duration"
import * as Effect from "effect/Effect"
import { pipe } from "effect/Function"
import * as Option from "effect/Option"
Expand Down Expand Up @@ -200,6 +201,16 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
const { fields } = formBuilder
const parsedMode = Mode.parse(config.mode)

// A zero (or absent) debounce means "fire synchronously", so only a strictly
// positive duration goes through `Atom.debounce`.
const positiveDebounce = (input: Duration.Input | null): Duration.Input | null =>
input !== null && Duration.toMillis(Duration.fromInputUnsafe(input)) > 0 ? input : null

const validationDebounce = parsedMode.validation === "onChange" && !parsedMode.autoSubmit
? positiveDebounce(parsedMode.debounce)
: null
const autoSubmitDebounce = positiveDebounce(parsedMode.debounce)

const combinedSchema = FormBuilder.buildSchema(formBuilder)

const stateAtom = Atom.make(Option.none<FormBuilder.FormState<TFields>>()).pipe(Atom.setIdleTTL(0))
Expand Down Expand Up @@ -406,37 +417,33 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
return shouldShowError ? validationError : Option.none()
}).pipe(Atom.setIdleTTL(0))

// Every value change produces a fresh box, so `Atom.debounce` (which drops
// updates that are `Object.is`-equal to its current value) still emits when
// the value returns to what it was before the burst of changes.
const debouncedChangeAtom = validationDebounce === null
? null
: Atom.debounce(
Atom.readable((get) => ({ value: get(valueAtom) })).pipe(Atom.setIdleTTL(0)),
validationDebounce
)

const triggerValidationAtom = Atom.readable((get) => {
let lastValue = get.once(valueAtom)
let timeout: ReturnType<typeof setTimeout> | undefined

const shouldDebounce = parsedMode.validation === "onChange" &&
parsedMode.debounce !== null && !parsedMode.autoSubmit
const debounceMs = shouldDebounce ? parsedMode.debounce : null

const trigger = (value: unknown) => {
if (!get.once(shouldValidateAtom)) return
if (debounceMs !== null && debounceMs > 0) {
if (timeout !== undefined) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = undefined
get.set(validationAtom, value)
}, debounceMs)
} else {
get.set(validationAtom, value)
}
if (debouncedChangeAtom !== null) {
get.mount(debouncedChangeAtom)
get.subscribe(debouncedChangeAtom, (change) => {
if (!get.once(shouldValidateAtom)) return
get.set(validationAtom, change.value)
})
} else {
let lastValue = get.once(valueAtom)
get.subscribe(valueAtom, (newValue) => {
if (newValue === lastValue) return
lastValue = newValue
if (!get.once(shouldValidateAtom)) return
get.set(validationAtom, newValue)
})
}

get.addFinalizer(() => {
if (timeout !== undefined) clearTimeout(timeout)
})

get.subscribe(valueAtom, (newValue) => {
if (newValue === lastValue) return
lastValue = newValue
trigger(newValue)
})

if (parsedMode.validation === "onBlur") {
get.subscribe(touchedAtom, (isTouched) => {
if (isTouched) {
Expand Down Expand Up @@ -840,71 +847,75 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
const keepAliveActiveAtom = Atom.make(false).pipe(Atom.setIdleTTL(0))

const autoSubmitAtom: Atom.Atom<void> = parsedMode.autoSubmit && parsedMode.validation === "onChange"
? Atom.readable((get) => {
const initialState = get.once(stateAtom)
let lastValues: unknown = Option.isSome(initialState)
? initialState.value.values
: null
let pendingChanges = false
let wasSubmitting = false
let timeout: ReturnType<typeof setTimeout> | undefined

const debounceMs = parsedMode.debounce

const triggerSubmit = () => {
if (AsyncResult.isWaiting(get.once(submitAtom))) {
pendingChanges = true
return
? (() => {
// Submit requests are funneled through a monotonically increasing counter
// so `Atom.debounce` can own the timer lifecycle: every bump restarts the
// trailing debounce window, and the subscriber below fires once it lands.
const submitRequestAtom = Atom.make(0).pipe(Atom.setIdleTTL(0))
const debouncedSubmitRequestAtom = autoSubmitDebounce === null
? null
: Atom.debounce(submitRequestAtom, autoSubmitDebounce)

return Atom.readable((get) => {
const initialState = get.once(stateAtom)
let lastValues: unknown = Option.isSome(initialState)
? initialState.value.values
: null
let pendingChanges = false
let wasSubmitting = false

const triggerSubmit = () => {
if (AsyncResult.isWaiting(get.once(submitAtom))) {
pendingChanges = true
return
}
get.set(submitAtom as Atom.Writable<any, any>, undefined)
}
get.set(submitAtom as Atom.Writable<any, any>, undefined)
}

const debouncedSubmit = () => {
if (debounceMs !== null && debounceMs > 0) {
if (timeout !== undefined) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = undefined
triggerSubmit()
}, debounceMs)
let requestSubmit: () => void
if (debouncedSubmitRequestAtom === null) {
requestSubmit = triggerSubmit
} else {
triggerSubmit()
get.mount(debouncedSubmitRequestAtom)
get.subscribe(debouncedSubmitRequestAtom, () => {
triggerSubmit()
})
requestSubmit = () => {
get.set(submitRequestAtom, get.once(submitRequestAtom) + 1)
}
}
}

get.addFinalizer(() => {
if (timeout !== undefined) clearTimeout(timeout)
})

get.subscribe(stateAtom, () => {
const state = get.once(stateAtom)
if (Option.isNone(state)) return
const currentValues = state.value.values
if (currentValues === lastValues) return
lastValues = currentValues

const submitResult = get.once(submitAtom)
if (AsyncResult.isWaiting(submitResult)) {
pendingChanges = true
} else {
debouncedSubmit()
}
})
get.subscribe(stateAtom, () => {
const state = get.once(stateAtom)
if (Option.isNone(state)) return
const currentValues = state.value.values
if (currentValues === lastValues) return
lastValues = currentValues

const submitResult = get.once(submitAtom)
if (AsyncResult.isWaiting(submitResult)) {
pendingChanges = true
} else {
requestSubmit()
}
})

get.subscribe(submitAtom, () => {
const result = get.once(submitAtom)
const isSubmitting = AsyncResult.isWaiting(result)
const justFinished = wasSubmitting && !isSubmitting
// Update wasSubmitting BEFORE triggering a follow-up submit. debouncedSubmit
// (no debounce) synchronously re-enters this subscription with the new
// waiting=true state; if we assigned wasSubmitting afterwards we'd clobber
// that re-entrant true with the stale false, losing the next change.
wasSubmitting = isSubmitting
if (justFinished && pendingChanges) {
pendingChanges = false
debouncedSubmit()
}
})
}).pipe(Atom.setIdleTTL(0))
get.subscribe(submitAtom, () => {
const result = get.once(submitAtom)
const isSubmitting = AsyncResult.isWaiting(result)
const justFinished = wasSubmitting && !isSubmitting
// Update wasSubmitting BEFORE triggering a follow-up submit. requestSubmit
// (no debounce) synchronously re-enters this subscription with the new
// waiting=true state; if we assigned wasSubmitting afterwards we'd clobber
// that re-entrant true with the stale false, losing the next change.
wasSubmitting = isSubmitting
if (justFinished && pendingChanges) {
pendingChanges = false
requestSubmit()
}
})
}).pipe(Atom.setIdleTTL(0))
})()
: Atom.readable(() => {}).pipe(Atom.setIdleTTL(0))

const onBlurSubmitAtom: Atom.Writable<void, void> = parsedMode.autoSubmit && parsedMode.validation === "onBlur"
Expand Down
10 changes: 3 additions & 7 deletions packages/form/src/Mode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as Duration from "effect/Duration"
import type * as Duration from "effect/Duration"

export type FormMode =
| { readonly validation?: "onSubmit"; readonly autoSubmit?: false; readonly debounce?: never }
Expand All @@ -12,7 +12,7 @@ export type FormModeWithoutAutoSubmit =

export interface ParsedMode {
readonly validation: "onSubmit" | "onBlur" | "onChange"
readonly debounce: number | null
readonly debounce: Duration.Input | null
readonly autoSubmit: boolean
}

Expand All @@ -24,11 +24,7 @@ export const parse = (mode?: FormMode): ParsedMode => {
}

if (validation === "onChange") {
const debounceMs = mode?.debounce === undefined
? null
: Duration.toMillis(Duration.fromInputUnsafe(mode.debounce))
const autoSubmit = mode?.autoSubmit === true
return { validation: "onChange", debounce: debounceMs, autoSubmit }
return { validation: "onChange", debounce: mode?.debounce ?? null, autoSubmit: mode?.autoSubmit === true }
}

return { validation: "onSubmit", debounce: null, autoSubmit: false }
Expand Down
Loading
Loading