@@ -28,6 +28,7 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
2828 const [ simulateFailure , setSimulateFailure ] = useState ( false ) ;
2929 const [ widgetKey , setWidgetKey ] = useState ( 0 ) ;
3030 const valueRef = useRef < string | null > ( null ) ;
31+ const errorRef = useRef ( false ) ;
3132 valueRef . current = value ;
3233
3334 useEffect ( ( ) => {
@@ -46,6 +47,9 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
4647
4748 switch ( e . detail . state ) {
4849 case 'error' :
50+ errorRef . current = true ;
51+ setAltchaVisible ( true ) ;
52+ break ;
4953 case 'code' :
5054 case 'unverified' :
5155 setAltchaVisible ( true ) ;
@@ -56,6 +60,7 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
5660 }
5761 break ;
5862 case 'verified' :
63+ errorRef . current = false ;
5964 if ( e . detail . payload ) {
6065 setValue ( e . detail . payload ) ;
6166 setAltchaVisible ( false ) ;
@@ -80,9 +85,13 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
8085 } ,
8186 verify ( ) : Promise < string > {
8287 const w = widgetRef . current ;
83- if ( ! w ) return Promise . reject ( new Error ( 'Altcha widget not mounted' ) ) ;
88+ // If widget failed to load or challenge endpoint is down,
89+ // resolve with empty string so the form can still submit.
90+ // The server decides whether to accept requests without captcha.
91+ if ( ! w ) return Promise . resolve ( '' ) ;
8492 const current = valueRef . current ;
8593 if ( current ) return Promise . resolve ( current ) ;
94+ if ( errorRef . current ) return Promise . resolve ( '' ) ;
8695 return new Promise ( ( resolve , reject ) => {
8796 const handler = ( ev : Event ) => {
8897 const e = ev as CustomEvent < { payload ?: string ; state : string } > ;
@@ -94,7 +103,9 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
94103 }
95104 if ( state === 'error' || state === 'expired' ) {
96105 w . removeEventListener ( 'statechange' , handler ) ;
97- reject ( new Error ( 'Captcha verification failed' ) ) ;
106+ // Resolve with empty string instead of rejecting
107+ // so the form submission can proceed to the server.
108+ resolve ( '' ) ;
98109 }
99110 } ;
100111 w . addEventListener ( 'statechange' , handler ) ;
@@ -116,6 +127,35 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
116127 widgetRef . current ?. reset ( ) ;
117128 } ;
118129
130+ // Remove the `required` attribute that altcha-widget sets on its internal
131+ // hidden checkbox. This prevents native browser form validation from
132+ // throwing "An invalid form control is not focusable" when the challenge
133+ // endpoint is unavailable and the checkbox can never be checked.
134+ // We handle verification entirely through the imperative verify() API.
135+ // biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-attach after remount
136+ useEffect ( ( ) => {
137+ if ( ! loaded ) return ;
138+ const w = widgetRef . current ;
139+ if ( ! w ) return ;
140+ const removeRequired = ( ) => {
141+ const root = w . shadowRoot ;
142+ if ( root ) {
143+ const checkbox = root . querySelector ( 'input[type="checkbox"]' ) ;
144+ if ( checkbox ) {
145+ checkbox . removeAttribute ( 'required' ) ;
146+ }
147+ const hiddenInput = root . querySelector ( 'input[type="hidden"]' ) ;
148+ if ( hiddenInput ) {
149+ hiddenInput . removeAttribute ( 'required' ) ;
150+ }
151+ }
152+ } ;
153+ // Run immediately and also after a short delay to catch async renders
154+ removeRequired ( ) ;
155+ const timer = setTimeout ( removeRequired , 500 ) ;
156+ return ( ) => clearTimeout ( timer ) ;
157+ } , [ loaded , widgetKey ] ) ;
158+
119159 if ( ! loaded ) return null ;
120160
121161 const devAttrs = devMode ? { debug : true , floatingpersist : 'focus' as const } : { } ;
0 commit comments