11import { html , isTemplate } from './html.js' ;
22import { BINDING_PREFIXES } from './binding-prefixes.js' ;
33import { escapeText , escapeAttr } from './escape.js' ;
4- import { assertNotFunctionActionAttr } from './form-action.js' ;
4+ import { assertNotFunctionActionAttr , commitFormActionAttr } from './form-action.js' ;
55import { lookup , lookupModuleUrl , allTags } from './registry.js' ;
66import { stylesToString , isCSS } from './css.js' ;
77import { isRepeat } from './repeat.js' ;
@@ -152,6 +152,13 @@ async function renderTemplate(tr, ctx) {
152152 let commentDashes = 0 ;
153153 let currentTag = '' ; // lowercased tag name currently being parsed
154154 let rawTail = '' ; // rolling lowercased tail, tracks </script>/</style>
155+ // Form-action binding (#1155). A `<form action=${action}>` commits its
156+ // attribute mid-tag, but the identity field it needs is a CHILD, so the
157+ // markup is buffered here and flushed the moment the open tag closes.
158+ // `seenAttrs` tracks what the author already wrote on the current tag so a
159+ // forced `method` / `enctype` never duplicates an explicit one.
160+ let pendingFormField = '' ;
161+ let seenAttrs = new Set ( ) ;
155162
156163 for ( let i = 0 ; i < strings . length ; i ++ ) {
157164 const s = strings [ i ] ;
@@ -195,6 +202,9 @@ async function renderTemplate(tr, ctx) {
195202 case 'in-tag' :
196203 out += c ;
197204 if ( c === '>' ) {
205+ // The open tag just closed, so a buffered form-action identity
206+ // field can now be emitted as the form's first child (#1155).
207+ if ( pendingFormField ) { out += pendingFormField ; pendingFormField = '' ; }
198208 state = isRawtextTag ( currentTag ) ? 'rawtext' : 'text' ;
199209 if ( state === 'rawtext' ) rawTail = '' ;
200210 } else if ( ! / \s / . test ( c ) && c !== '/' ) {
@@ -213,20 +223,34 @@ async function renderTemplate(tr, ctx) {
213223 }
214224 break ;
215225 case 'attr-name' :
216- if ( c === '=' ) { state = 'after-eq' ; out += c ; }
217- else if ( / \s / . test ( c ) ) { state = 'in-tag' ; attrName = '' ; out += c ; }
218- else if ( c === '>' ) { state = 'text' ; attrName = '' ; out += c ; }
226+ // Record every attribute the author wrote on this tag, so a forced
227+ // method/enctype (#1155) never duplicates an explicit one.
228+ if ( c === '=' ) { state = 'after-eq' ; seenAttrs . add ( attrName . toLowerCase ( ) ) ; out += c ; }
229+ else if ( / \s / . test ( c ) ) { state = 'in-tag' ; seenAttrs . add ( attrName . toLowerCase ( ) ) ; attrName = '' ; out += c ; }
230+ else if ( c === '>' ) {
231+ state = 'text' ;
232+ seenAttrs . add ( attrName . toLowerCase ( ) ) ;
233+ attrName = '' ;
234+ out += c ;
235+ if ( pendingFormField ) { out += pendingFormField ; pendingFormField = '' ; }
236+ }
219237 else { attrName += c ; out += c ; }
220238 break ;
221239 case 'after-eq' :
222240 if ( c === '"' || c === "'" ) { state = 'attr-quoted' ; attrQuote = c ; out += c ; }
223241 else if ( / \s / . test ( c ) ) { state = 'in-tag' ; attrName = '' ; out += c ; }
224- else if ( c === '>' ) { state = 'text' ; attrName = '' ; out += c ; }
242+ else if ( c === '>' ) {
243+ state = 'text' ; attrName = '' ; out += c ;
244+ if ( pendingFormField ) { out += pendingFormField ; pendingFormField = '' ; }
245+ }
225246 else { state = 'attr-unquoted' ; out += c ; }
226247 break ;
227248 case 'attr-unquoted' :
228249 if ( / \s / . test ( c ) ) { state = 'in-tag' ; attrName = '' ; out += c ; }
229- else if ( c === '>' ) { state = 'text' ; attrName = '' ; out += c ; }
250+ else if ( c === '>' ) {
251+ state = 'text' ; attrName = '' ; out += c ;
252+ if ( pendingFormField ) { out += pendingFormField ; pendingFormField = '' ; }
253+ }
230254 else out += c ;
231255 break ;
232256 case 'attr-quoted' :
@@ -324,16 +348,22 @@ async function renderTemplate(tr, ctx) {
324348 state = 'in-tag' ;
325349 attrName = '' ;
326350 } else {
327- // #1154: never stringify a function into action=/formaction= (it
328- // would serialize a server action's source into the served HTML).
329- assertNotFunctionActionAttr ( val , attrName , currentTag ) ;
330- out += `"${ escapeAttr ( String ( val ?? '' ) ) } "` ;
351+ // A function bound to action=/formaction= is either an identifiable
352+ // server action (emit the identity field, #1155) or refused
353+ // outright (#1154); it is never stringified.
354+ const bound = commitFormActionAttr ( val , attrName , currentTag , seenAttrs ) ;
355+ if ( bound ) {
356+ out += bound . attrValue + bound . forced ;
357+ pendingFormField = bound . field ;
358+ } else {
359+ out += `"${ escapeAttr ( String ( val ?? '' ) ) } "` ;
360+ }
331361 state = 'in-tag' ;
332362 attrName = '' ;
333363 }
334364 } else if ( state === 'attr-quoted' || state === 'attr-unquoted' ) {
335- // Same guard for a hole inside a quoted/unquoted value, the
336- // `action="${fn}"` and mixed `action="/x/${fn}"` shapes (#1154) .
365+ // A hole INSIDE a value (`action="${fn}"`, mixed `action="/x/${fn}"`)
366+ // cannot carry an identity field, so a function here is always refused .
337367 assertNotFunctionActionAttr ( val , attrName , currentTag ) ;
338368 out += escapeAttr ( String ( val ?? '' ) ) ;
339369 }
@@ -1775,6 +1805,9 @@ async function streamTemplate(tr, ctx, controller) {
17751805 let rawTail = '' ;
17761806 // Buffer used for attribute handling where we may need to backtrack.
17771807 let buf = '' ;
1808+ // Form-action binding (#1155), mirroring renderTemplate.
1809+ let pendingFormField = '' ;
1810+ let seenAttrs = new Set ( ) ;
17781811
17791812 for ( let i = 0 ; i < strings . length ; i ++ ) {
17801813 const s = strings [ i ] ;
@@ -1788,8 +1821,8 @@ async function streamTemplate(tr, ctx, controller) {
17881821 case 'tag-open' :
17891822 buf += c ;
17901823 if ( c === '!' ) state = 'bang-1' ;
1791- else if ( c === '/' ) { state = 'tag-name' ; currentTag = '' ; }
1792- else if ( / [ a - z A - Z ] / . test ( c ) ) { state = 'tag-name' ; currentTag = c . toLowerCase ( ) ; }
1824+ else if ( c === '/' ) { state = 'tag-name' ; currentTag = '' ; seenAttrs = new Set ( ) ; }
1825+ else if ( / [ a - z A - Z ] / . test ( c ) ) { state = 'tag-name' ; currentTag = c . toLowerCase ( ) ; seenAttrs = new Set ( ) ; }
17931826 else state = 'text' ;
17941827 break ;
17951828 case 'bang-1' :
@@ -1818,6 +1851,7 @@ async function streamTemplate(tr, ctx, controller) {
18181851 case 'in-tag' :
18191852 buf += c ;
18201853 if ( c === '>' ) {
1854+ if ( pendingFormField ) { buf += pendingFormField ; pendingFormField = '' ; }
18211855 state = isRawtextTag ( currentTag ) ? 'rawtext' : 'text' ;
18221856 if ( state === 'rawtext' ) rawTail = '' ;
18231857 } else if ( ! / \s / . test ( c ) && c !== '/' ) {
@@ -1836,20 +1870,29 @@ async function streamTemplate(tr, ctx, controller) {
18361870 }
18371871 break ;
18381872 case 'attr-name' :
1839- if ( c === '=' ) { state = 'after-eq' ; buf += c ; }
1840- else if ( / \s / . test ( c ) ) { state = 'in-tag' ; attrName = '' ; buf += c ; }
1841- else if ( c === '>' ) { state = 'text' ; attrName = '' ; buf += c ; }
1873+ if ( c === '=' ) { state = 'after-eq' ; seenAttrs . add ( attrName . toLowerCase ( ) ) ; buf += c ; }
1874+ else if ( / \s / . test ( c ) ) { state = 'in-tag' ; seenAttrs . add ( attrName . toLowerCase ( ) ) ; attrName = '' ; buf += c ; }
1875+ else if ( c === '>' ) {
1876+ state = 'text' ; seenAttrs . add ( attrName . toLowerCase ( ) ) ; attrName = '' ; buf += c ;
1877+ if ( pendingFormField ) { buf += pendingFormField ; pendingFormField = '' ; }
1878+ }
18421879 else { attrName += c ; buf += c ; }
18431880 break ;
18441881 case 'after-eq' :
18451882 if ( c === '"' || c === "'" ) { state = 'attr-quoted' ; attrQuote = c ; buf += c ; }
18461883 else if ( / \s / . test ( c ) ) { state = 'in-tag' ; attrName = '' ; buf += c ; }
1847- else if ( c === '>' ) { state = 'text' ; attrName = '' ; buf += c ; }
1884+ else if ( c === '>' ) {
1885+ state = 'text' ; attrName = '' ; buf += c ;
1886+ if ( pendingFormField ) { buf += pendingFormField ; pendingFormField = '' ; }
1887+ }
18481888 else { state = 'attr-unquoted' ; buf += c ; }
18491889 break ;
18501890 case 'attr-unquoted' :
18511891 if ( / \s / . test ( c ) ) { state = 'in-tag' ; attrName = '' ; buf += c ; }
1852- else if ( c === '>' ) { state = 'text' ; attrName = '' ; buf += c ; }
1892+ else if ( c === '>' ) {
1893+ state = 'text' ; attrName = '' ; buf += c ;
1894+ if ( pendingFormField ) { buf += pendingFormField ; pendingFormField = '' ; }
1895+ }
18531896 else buf += c ;
18541897 break ;
18551898 case 'attr-quoted' :
@@ -1891,11 +1934,21 @@ async function streamTemplate(tr, ctx, controller) {
18911934 state = 'in-tag' ;
18921935 attrName = '' ;
18931936 } else {
1894- buf += `"${ escapeAttr ( String ( val ?? '' ) ) } "` ;
1937+ // Same contract as the buffered renderer (they must not diverge; see
1938+ // commitFormActionAttr). Before this, a STREAMED page emitted a bound
1939+ // action's source verbatim while the buffered path already refused it.
1940+ const bound = commitFormActionAttr ( val , attrName , currentTag , seenAttrs ) ;
1941+ if ( bound ) {
1942+ buf += bound . attrValue + bound . forced ;
1943+ pendingFormField = bound . field ;
1944+ } else {
1945+ buf += `"${ escapeAttr ( String ( val ?? '' ) ) } "` ;
1946+ }
18951947 state = 'in-tag' ;
18961948 attrName = '' ;
18971949 }
18981950 } else if ( state === 'attr-quoted' || state === 'attr-unquoted' ) {
1951+ assertNotFunctionActionAttr ( val , attrName , currentTag ) ;
18991952 buf += escapeAttr ( String ( val ?? '' ) ) ;
19001953 }
19011954 }
0 commit comments