what-server 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -5,6 +5,9 @@
5
5
  import {
6
6
  h,
7
7
  _isAriaAttr,
8
+ _beginComponentSSR,
9
+ _endComponentSSR,
10
+ _mapArrayToArray,
8
11
  getServerContext,
9
12
  runWithServerContext,
10
13
  beginHeadCollection,
@@ -58,6 +61,94 @@ export function renderToHydratableString(vnode) {
58
61
  return _renderHydratable(vnode);
59
62
  }
60
63
 
64
+ // <ErrorBoundary> and <Suspense> are not elements. They return vnodes carrying
65
+ // the internal marker tags '__errorBoundary' / '__suspense', which every client
66
+ // path routes to a boundary handler (dom.js:349-353). The server had no such
67
+ // routing outside renderToString's suspense case, so the marker tags fell
68
+ // through to the generic element renderer and three things went wrong at once:
69
+ //
70
+ // <ErrorBoundary> -> <__errorBoundary><p>ok</p></__errorBoundary>
71
+ // <Suspense> (hydratable) -> <__suspense boundary="[object Object]"
72
+ // fallback="[object Object]">...
73
+ //
74
+ // An invalid element name in the response, the boundary's internal props
75
+ // stringified into attributes, and, worst of the three, a component that threw
76
+ // during SSR took down the WHOLE page render instead of being contained:
77
+ // renderToString and renderToHydratableString both propagated the error, so the
78
+ // one construct whose entire purpose is to stop a subtree failure from becoming
79
+ // a page failure did nothing on the server. The stream path did not throw but
80
+ // emitted an HTML comment where the fallback belonged.
81
+ //
82
+ // A thenable is deliberately re-thrown rather than caught: that is a suspended
83
+ // resource, not an error, and it belongs to the nearest <Suspense>.
84
+ const BOUNDARY_TAGS = new Set(['__errorBoundary', '__suspense']);
85
+
86
+ // <Portal> is client-only by the framework's own decision: Portal() returns null
87
+ // when there is no `document` (helpers.js:153). That guard is environmental, not
88
+ // a server check, so SSR under a DOM shim (jsdom, happy-dom, a test harness, any
89
+ // runtime that polyfills document) sailed past it, built the vnode, and handed
90
+ // the server a '__portal' tag it had no branch for. The result was
91
+ //
92
+ // <__portal container="[object HTMLDivElement]"><p>inside</p></__portal>
93
+ //
94
+ // which is three wrongs at once: an invalid element, a DOM node stringified into
95
+ // an attribute, and the portal's content emitted INLINE at the portal's own
96
+ // position rather than at its target. Hydration would then move it, so the
97
+ // server markup contradicted the client's on purpose.
98
+ //
99
+ // Rendering nothing is the answer that agrees with the no-document path, so the
100
+ // same app produces the same HTML whether or not a shim happens to be loaded.
101
+ const SERVER_SKIPPED_TAGS = new Set(['__portal']);
102
+
103
+ // The props a component is called with. The server has to hand a component
104
+ // exactly what the client hands it, and it did not.
105
+ //
106
+ // dom.js:593 collapses the children list before it becomes a prop, and when
107
+ // there are NO children it sets no `children` key at all, so an ordinary JS
108
+ // default parameter
109
+ //
110
+ // function SkipLink({ children = 'Skip to content' }) { ... }
111
+ //
112
+ // applies. The server passed `children: vnode.children` verbatim, which is `[]`
113
+ // for a childless component, and `[]` is defined, so the default NEVER applied
114
+ // server-side. A server-rendered <SkipLink /> shipped `<a href="#main"></a>`: a
115
+ // link with no accessible name (a WCAG 2.4.4 failure) until the client hydrated
116
+ // and replaced it, which is exactly the case where the markup has to stand on
117
+ // its own. The divergence is general, not specific to SkipLink: every component
118
+ // with a defaulted children prop rendered one thing on the client and another on
119
+ // the server. The same blanket override also discarded children passed as a
120
+ // plain PROP (`h(Card, { children: body })`), which the client keeps precisely
121
+ // because there are no vnode children to replace it with.
122
+ //
123
+ // "Childless" has to mean what it means on the client: an EMPTY list. h() drops
124
+ // null/false/true children (h.js:48,66) but keeps '' as the string child it is,
125
+ // so `<Comp>{''}</Comp>` has one child and the default must NOT apply there.
126
+ // Only length 0 counts, plus a hand-built vnode carrying no children array.
127
+ //
128
+ // Not yet matched: the client also UNWRAPS a single child (`[child]` -> `child`).
129
+ // Aligning that here alone would break <Island>, which puts the prop straight
130
+ // back onto a vnode's children (islands.js:243) where the element renderer
131
+ // requires an array. That one needs both sides changed together.
132
+ function _componentProps(vnode) {
133
+ const children = vnode.children;
134
+ if (!children || children.length === 0) return { ...vnode.props };
135
+ return { ...vnode.props, children };
136
+ }
137
+
138
+ function _boundaryFallback(vnode, error) {
139
+ const fallback = vnode.props && vnode.props.fallback;
140
+ if (typeof fallback !== 'function') return fallback;
141
+ const reset = (vnode.props && vnode.props.reset) || (() => {});
142
+ try {
143
+ return fallback({ error, reset });
144
+ } catch (e) {
145
+ if (_isDevMode) {
146
+ console.warn(`[what-server] <ErrorBoundary> fallback threw during SSR: ${e.message}`);
147
+ }
148
+ return null;
149
+ }
150
+ }
151
+
61
152
  function _renderHydratable(vnode) {
62
153
  if (vnode == null || vnode === false || vnode === true) return '';
63
154
 
@@ -71,6 +162,20 @@ function _renderHydratable(vnode) {
71
162
  return `<!--$-->${_renderHydratable(vnode())}<!--/$-->`;
72
163
  }
73
164
 
165
+ // Compiled keyed list: an inserter taking (parent, marker), not a thunk.
166
+ // Calling it with no arguments threw and SSR swallowed it, so every compiled
167
+ // keyed list server-rendered as an empty container. See _mapArrayToArray.
168
+ if (typeof vnode === 'function' && vnode._mapArray) {
169
+ try {
170
+ return `<!--$-->${_renderHydratable(_mapArrayToArray(vnode))}<!--/$-->`;
171
+ } catch (e) {
172
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
173
+ console.warn('[what-server] Error rendering keyed list in SSR:', e.message);
174
+ }
175
+ return '<!--$--><!--/$-->';
176
+ }
177
+ }
178
+
74
179
  // Reactive function child — wrap in dynamic content markers
75
180
  if (typeof vnode === 'function') {
76
181
  try {
@@ -88,11 +193,30 @@ function _renderHydratable(vnode) {
88
193
  return `<!--[]-->${vnode.map(_renderHydratable).join('')}<!--/[]-->`;
89
194
  }
90
195
 
196
+ if (SERVER_SKIPPED_TAGS.has(vnode.tag)) return '';
197
+
198
+ // Boundary markers render their subtree, never themselves.
199
+ if (BOUNDARY_TAGS.has(vnode.tag)) {
200
+ try {
201
+ return (vnode.children || []).map(_renderHydratable).join('');
202
+ } catch (e) {
203
+ const suspended = e && typeof e.then === 'function';
204
+ if (suspended && vnode.tag === '__errorBoundary') throw e; // belongs to <Suspense>
205
+ if (!suspended && vnode.tag === '__suspense') throw e; // belongs to <ErrorBoundary>
206
+ return _renderHydratable(_boundaryFallback(vnode, suspended ? null : e));
207
+ }
208
+ }
209
+
91
210
  // Component — add hydration key to root element
92
211
  if (typeof vnode.tag === 'function') {
93
212
  const hkId = nextHydrationId();
94
- const result = vnode.tag({ ...vnode.props, children: vnode.children });
95
- const html = _renderHydratable(result);
213
+ const ctx = _beginComponentSSR(vnode.tag);
214
+ let html;
215
+ try {
216
+ html = _renderHydratable(vnode.tag(_componentProps(vnode)));
217
+ } finally {
218
+ _endComponentSSR(ctx);
219
+ }
96
220
  // Inject data-hk into the first element tag if present
97
221
  return injectHydrationKey(html, hkId);
98
222
  }
@@ -119,6 +243,19 @@ function injectHydrationKey(html, hkId) {
119
243
  const prefix = match[1];
120
244
  const tagName = match[2];
121
245
  const insertAt = prefix.length + 1 + tagName.length; // after '<tagName'
246
+
247
+ // One element, one key. Every component in a chain injects into the first
248
+ // element of its rendered output, and a component that returns another
249
+ // component (the most ordinary composition there is) resolves to the SAME
250
+ // element at every level, so `Outer -> Middle -> Inner -> <p>` emitted
251
+ // `<p data-hk="h0" data-hk="h1" data-hk="h2">`: a duplicate attribute, which
252
+ // is invalid HTML, and browsers silently keep only the first. The innermost
253
+ // component is the one that actually owns the element, and it wins because
254
+ // it renders first, so an existing key means there is nothing to do here.
255
+ const openTagEnd = html.indexOf('>', insertAt);
256
+ const openTag = html.slice(insertAt, openTagEnd === -1 ? undefined : openTagEnd);
257
+ if (/[\s"']data-hk\s*=/.test(openTag) || /^\s*data-hk\s*=/.test(openTag)) return html;
258
+
122
259
  return html.slice(0, insertAt) + ` data-hk="${hkId}"` + html.slice(insertAt);
123
260
  }
124
261
  return html;
@@ -146,6 +283,18 @@ export function renderToString(vnode) {
146
283
  return renderToString(vnode());
147
284
  }
148
285
 
286
+ // Compiled keyed list: see _renderHydratable above.
287
+ if (typeof vnode === 'function' && vnode._mapArray) {
288
+ try {
289
+ return renderToString(_mapArrayToArray(vnode));
290
+ } catch (e) {
291
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
292
+ console.warn('[what-server] Error rendering keyed list in SSR:', e.message);
293
+ }
294
+ return '';
295
+ }
296
+ }
297
+
149
298
  // Reactive function child — call to get value
150
299
  if (typeof vnode === 'function') {
151
300
  try {
@@ -163,24 +312,42 @@ export function renderToString(vnode) {
163
312
  return vnode.map(renderToString).join('');
164
313
  }
165
314
 
166
- // Suspense boundary — render children; if a child suspends (throws a thenable),
167
- // show the fallback (a synchronous render cannot await). renderToStringAsync /
168
- // renderToStream await the pending resources and re-render with real content.
169
- if (vnode.tag === '__suspense') {
315
+ if (SERVER_SKIPPED_TAGS.has(vnode.tag)) return '';
316
+
317
+ // Boundary markers render their subtree, never themselves.
318
+ //
319
+ // <Suspense>: if a child suspends (throws a thenable), show the fallback, since
320
+ // a synchronous render cannot await. renderToStringAsync / renderToStream await
321
+ // the pending resources and re-render with real content.
322
+ //
323
+ // <ErrorBoundary>: if a child throws a real error, show the fallback. Before
324
+ // this branch existed the error propagated out of renderToString and killed the
325
+ // whole page response, which is precisely the failure an ErrorBoundary exists
326
+ // to prevent.
327
+ if (BOUNDARY_TAGS.has(vnode.tag)) {
170
328
  try {
171
329
  return (vnode.children || []).map(renderToString).join('');
172
330
  } catch (e) {
173
- if (e && typeof e.then === 'function') {
174
- return renderToString(vnode.props && vnode.props.fallback);
175
- }
176
- throw e;
331
+ const suspended = e && typeof e.then === 'function';
332
+ if (suspended && vnode.tag === '__errorBoundary') throw e; // belongs to <Suspense>
333
+ if (!suspended && vnode.tag === '__suspense') throw e; // belongs to <ErrorBoundary>
334
+ return renderToString(_boundaryFallback(vnode, suspended ? null : e));
177
335
  }
178
336
  }
179
337
 
180
338
  // Component
339
+ //
340
+ // Run it under a component context. Calling it bare left the component stack
341
+ // empty, so every context-dependent hook threw and the render failed outright
342
+ // rather than degrading. The context has to stay on the stack while the
343
+ // result is rendered, because useContext resolves by walking parent contexts.
181
344
  if (typeof vnode.tag === 'function') {
182
- const result = vnode.tag({ ...vnode.props, children: vnode.children });
183
- return renderToString(result);
345
+ const ctx = _beginComponentSSR(vnode.tag);
346
+ try {
347
+ return renderToString(vnode.tag(_componentProps(vnode)));
348
+ } finally {
349
+ _endComponentSSR(ctx);
350
+ }
184
351
  }
185
352
 
186
353
  // Element
@@ -315,6 +482,20 @@ export async function* renderToStream(vnode, ctx) {
315
482
  return;
316
483
  }
317
484
 
485
+ // Compiled keyed list: see _renderHydratable above.
486
+ if (typeof vnode === 'function' && vnode._mapArray) {
487
+ let rows = null;
488
+ try {
489
+ rows = runWithServerContext(ctx, () => _mapArrayToArray(vnode));
490
+ } catch (e) {
491
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
492
+ console.warn('[what-server] Error rendering keyed list in stream SSR:', e.message);
493
+ }
494
+ }
495
+ if (rows) yield* renderToStream(rows, ctx);
496
+ return;
497
+ }
498
+
318
499
  // Reactive function child — call to get value
319
500
  if (typeof vnode === 'function') {
320
501
  try {
@@ -335,6 +516,24 @@ export async function* renderToStream(vnode, ctx) {
335
516
  return;
336
517
  }
337
518
 
519
+ if (SERVER_SKIPPED_TAGS.has(vnode.tag)) return;
520
+
521
+ // Error boundary: render the subtree, and on a real error emit the fallback
522
+ // rather than the generic component-error comment the catch below would
523
+ // produce. Rendered eagerly into a string instead of streamed, because a
524
+ // boundary that has already yielded half its subtree cannot take it back.
525
+ if (vnode.tag === '__errorBoundary') {
526
+ let html;
527
+ try {
528
+ html = runWithServerContext(ctx, () => (vnode.children || []).map(renderToString).join(''));
529
+ } catch (e) {
530
+ if (e && typeof e.then === 'function') throw e; // suspended; belongs to <Suspense>
531
+ html = runWithServerContext(ctx, () => renderToString(_boundaryFallback(vnode, e)));
532
+ }
533
+ yield html;
534
+ return;
535
+ }
536
+
338
537
  // Suspense boundary — render the subtree, awaiting any suspended resources,
339
538
  // then emit the resolved content. (In-order; out-of-order swap is a future
340
539
  // enhancement.) The synchronous render runs inside the threaded ctx.
@@ -361,10 +560,14 @@ export async function* renderToStream(vnode, ctx) {
361
560
  }
362
561
 
363
562
  if (typeof vnode.tag === 'function') {
563
+ // The frame stays open across the yields below: useContext resolves by
564
+ // walking parent contexts, so a Provider's context has to outlive the
565
+ // streaming of its own subtree.
566
+ const componentCtx = _beginComponentSSR(vnode.tag);
364
567
  try {
365
568
  const result = runWithServerContext(
366
569
  ctx,
367
- () => vnode.tag({ ...vnode.props, children: vnode.children })
570
+ () => vnode.tag(_componentProps(vnode))
368
571
  );
369
572
  // Support async components
370
573
  const resolved = result instanceof Promise ? await result : result;
@@ -376,6 +579,8 @@ export async function* renderToStream(vnode, ctx) {
376
579
  yield _isDevMode
377
580
  ? `<!-- SSR Error: ${escapeHtml(e.message || 'Component error')} -->`
378
581
  : `<!-- SSR Error -->`;
582
+ } finally {
583
+ _endComponentSSR(componentCtx);
379
584
  }
380
585
  return;
381
586
  }
@@ -414,10 +619,19 @@ export function definePage(config) {
414
619
  // Generate static HTML for a page
415
620
  export function generateStaticPage(page, data = {}) {
416
621
  const ctx = createRenderContext(data);
417
- const html = runWithServerContext(ctx, () => {
418
- const vnode = page.component(data);
419
- return renderToString(vnode);
420
- });
622
+ // Render the page as a vnode instead of calling the component and handing the
623
+ // result over. `page.component(data)` ran BARE, outside the component frame
624
+ // renderToString establishes, so nothing was on the component stack and every
625
+ // context-dependent hook threw: useState, useSignal, useEffect, useMemo,
626
+ // useRef, onMount and <Ctx.Provider> all resolve through
627
+ // getCurrentComponent(). One hook anywhere in the page component's own body
628
+ // meant THE documented static-generation entry point could not render it at
629
+ // all, which rules out most real pages.
630
+ //
631
+ // `data` still reaches the component as its props (the same convention
632
+ // renderPage uses), and the body HTML this produces is byte-identical for a
633
+ // page that does not use hooks, so what wrapDocument receives is unchanged.
634
+ const html = runWithServerContext(ctx, () => renderToString(h(page.component, data)));
421
635
  const islands = page.islands || [];
422
636
 
423
637
  return wrapDocument({
@@ -539,10 +753,40 @@ function assertSafeTag(tag) {
539
753
 
540
754
  function renderAttrs(props) {
541
755
  let out = '';
542
- for (const [key, val] of Object.entries(props)) {
756
+ for (const [key, rawVal] of Object.entries(props)) {
543
757
  if (key === 'key' || key === 'ref' || key === 'children' || key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
544
758
  const lowerKey = key.toLowerCase();
545
759
  if (lowerKey.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
760
+
761
+ // A remaining function value is a reactive accessor, so CALL it. Every
762
+ // client path already does (setProp and setAttr both resolve a function
763
+ // value); only the server did not, and fell through to String(val), which
764
+ // stringifies a function as its SOURCE TEXT. So the documented way to make
765
+ // an attribute reactive,
766
+ //
767
+ // <span className={() => theme()}>
768
+ //
769
+ // server-rendered as class="() =&gt; theme()": the page shipped JavaScript
770
+ // source in its class attribute, lost the real class until hydration
771
+ // replaced it, and any CSS keyed on that class did not apply to the HTML a
772
+ // crawler or a no-JS visitor saw. what-router's <Link> hit it on every
773
+ // link, because Link always passes a thunk as `class`.
774
+ //
775
+ // Fail soft on a throw, matching the rest of this function: one attribute
776
+ // that cannot resolve must not take down the whole response.
777
+ let val = rawVal;
778
+ if (typeof val === 'function') {
779
+ try {
780
+ val = val();
781
+ } catch (e) {
782
+ if (_isDevMode) {
783
+ console.warn(`[what-server] Skipping attribute ${JSON.stringify(key)}: its value threw during SSR: ${e.message}`);
784
+ }
785
+ continue;
786
+ }
787
+ // A resolved value that is itself a function is not an attribute value.
788
+ if (typeof val === 'function') continue;
789
+ }
546
790
  // aria-*/role are enumerated, so `false` is a real value and must survive:
547
791
  // an absent `aria-expanded` means "unsupported", `aria-expanded="false"`
548
792
  // means "collapsed". Every other attribute keeps HTML boolean semantics,
package/src/islands.js CHANGED
@@ -473,54 +473,180 @@ export function enhance(selector, handler) {
473
473
  }
474
474
  }
475
475
 
476
- // Form enhancement: submit via fetch instead of page reload
476
+ /**
477
+ * Recover the double-submit CSRF token for a form.
478
+ *
479
+ * The meta tag alone is not enough. A cached page (mode 'static' or 'hybrid') is
480
+ * shared between visitors, so the adapter deliberately does NOT embed a
481
+ * per-visitor token in it; the token lives only in the cookie. A form inside
482
+ * such a page therefore has no meta tag to read, and blocking on that alone
483
+ * refused perfectly valid submissions. `<Form>` also emits the token as a hidden
484
+ * field, which is the same value, so all three sources are checked.
485
+ */
486
+ function readFormCsrfToken(form) {
487
+ const meta = document.querySelector('meta[name="csrf-token"]')
488
+ || document.querySelector('meta[name="what-csrf-token"]');
489
+ if (meta) return meta.getAttribute('content');
490
+
491
+ const field = form.querySelector('input[name="what-csrf-token"], input[name="_csrf"]');
492
+ if (field && field.value) return field.value;
493
+
494
+ const cookie = document.cookie.match(/(?:^|;\s*)what-csrf=([^;]+)/);
495
+ return cookie ? decodeURIComponent(cookie[1]) : null;
496
+ }
497
+
498
+ /**
499
+ * Serialize a form the way the browser would, so an enhanced submit and a plain
500
+ * one are indistinguishable to the server.
501
+ *
502
+ * Two details that look pedantic and are not:
503
+ * - newlines in every text entry normalize to CRLF. The urlencoded and
504
+ * multipart serializers both do this per spec; URLSearchParams does not, so
505
+ * a <textarea> round-tripped different bytes with JS on than with JS off.
506
+ * - a File under a non-multipart encoding contributes only its NAME, which is
507
+ * what a native submit sends. Sending "[object File]" would be worse than
508
+ * useless, and silently sending nothing hides a real mistake.
509
+ */
510
+ function formEntries(form, submitter, multipart) {
511
+ const data = new FormData(form);
512
+
513
+ // FormData(form) never includes the submit button, so a multi-button form
514
+ // could not tell the server which button was pressed. Native submits include
515
+ // the submitter's name/value.
516
+ if (submitter && submitter.name) {
517
+ data.append(submitter.name, submitter.value ?? '');
518
+ }
519
+
520
+ if (multipart) return data;
521
+
522
+ const params = new URLSearchParams();
523
+ let droppedFile = null;
524
+ for (const [key, value] of data) {
525
+ if (typeof value === 'string') {
526
+ params.append(key, value.replace(/\r\n|\r|\n/g, '\r\n'));
527
+ } else {
528
+ droppedFile = droppedFile || key;
529
+ params.append(key, value.name ?? '');
530
+ }
531
+ }
532
+
533
+ if (droppedFile && typeof console !== 'undefined') {
534
+ console.warn(
535
+ `[what] Form field "${droppedFile}" holds a file, but the form is not ` +
536
+ 'enctype="multipart/form-data", so only the file NAME is sent. This is what ' +
537
+ 'a plain HTML submit does too. Add enctype="multipart/form-data" to upload ' +
538
+ 'the bytes.'
539
+ );
540
+ }
541
+ return params;
542
+ }
543
+
544
+ /**
545
+ * Form enhancement: submit via fetch instead of a full page load.
546
+ *
547
+ * The encoding is not a detail. `/__what_action` parses
548
+ * application/x-www-form-urlencoded or JSON, never multipart, so an enhanced
549
+ * submit of a default form has to use the same encoding as the plain HTML submit
550
+ * it replaces. Posting a FormData object unconditionally produced a multipart
551
+ * body the endpoint could not parse: the action id went missing and every
552
+ * enhanced `<Form>` failed with 400 while the no-JS path kept working.
553
+ *
554
+ * The encoding now follows the form's own `enctype`, exactly as the browser
555
+ * would, so a form that declares multipart still uploads its files.
556
+ *
557
+ * On success the action endpoint answers 303 to the form's `_redirect` target,
558
+ * which fetch follows. The page is then navigated there so the enhanced path
559
+ * lands where the unenhanced one would. Cancel the `form:response` event to keep
560
+ * the page put and handle the response yourself.
561
+ */
477
562
  export function enhanceForms(selector = 'form[data-enhance]') {
478
563
  enhance(selector, (form) => {
479
- form.addEventListener('submit', async (e) => {
480
- e.preventDefault();
481
-
482
- const formData = new FormData(form);
483
- const method = form.method.toUpperCase() || 'POST';
484
- const action = form.action || location.href;
564
+ form.addEventListener('submit', async (event) => {
565
+ event.preventDefault();
485
566
 
486
567
  try {
487
- // Read CSRF token from meta tag
488
- const csrfMeta = document.querySelector('meta[name="csrf-token"]') ||
489
- document.querySelector('meta[name="what-csrf-token"]');
490
- const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : null;
491
-
492
- // If no CSRF token and form hasn't opted out, block submission
493
- const noCsrf = form.getAttribute('data-no-csrf') === 'true';
494
- if (!csrfToken && !noCsrf) {
495
- console.warn(
496
- '[what] Form submission blocked: no CSRF token found. ' +
497
- 'Add a <meta name="what-csrf-token"> tag (csrfMetaTag() emits it) ' +
498
- 'or set data-no-csrf="true" on the form to opt out.'
499
- );
500
- form.dispatchEvent(new CustomEvent('form:error', {
501
- bubbles: true,
502
- detail: { error: new Error('Missing CSRF token') },
503
- }));
504
- return;
568
+ // getAttribute, never the properties. HTMLFormElement is
569
+ // [LegacyOverrideBuiltIns]: a field named "method" or "action" SHADOWS
570
+ // form.method / form.action with the input element itself. Reading
571
+ // `form.method.toUpperCase()` then threw a TypeError after
572
+ // preventDefault() had already run, so the submit produced no fetch, no
573
+ // form:error, and no native fallback. It just did nothing.
574
+ const submitter = event.submitter || null;
575
+ const attr = (name) => (submitter && submitter.getAttribute(`form${name}`))
576
+ || form.getAttribute(name);
577
+
578
+ const method = (attr('method') || 'get').toUpperCase();
579
+ const action = new URL(attr('action') || location.href, location.href);
580
+ const enctype = (attr('enctype') || '').toLowerCase();
581
+ const multipart = enctype === 'multipart/form-data';
582
+
583
+ const entries = formEntries(form, submitter, multipart);
584
+
585
+ // CSRF is about protecting OUR endpoint. A form aimed at another origin
586
+ // must neither be blocked by our token policy nor be handed our token:
587
+ // attaching it would export the visitor's double-submit secret to a
588
+ // third party.
589
+ const sameOrigin = action.origin === location.origin;
590
+ const headers = { 'X-Requested-With': 'XMLHttpRequest' };
591
+
592
+ if (sameOrigin) {
593
+ const csrfToken = readFormCsrfToken(form);
594
+ const noCsrf = form.getAttribute('data-no-csrf') === 'true';
595
+ if (!csrfToken && !noCsrf) {
596
+ console.warn(
597
+ '[what] Form submission blocked: no CSRF token found. ' +
598
+ 'Add a <meta name="what-csrf-token"> tag (csrfMetaTag() emits it) ' +
599
+ 'or set data-no-csrf="true" on the form to opt out.'
600
+ );
601
+ form.dispatchEvent(new CustomEvent('form:error', {
602
+ bubbles: true,
603
+ detail: { error: new Error('Missing CSRF token') },
604
+ }));
605
+ return;
606
+ }
607
+ if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
505
608
  }
506
609
 
507
- const headers = {
508
- 'X-Requested-With': 'XMLHttpRequest',
509
- };
510
- if (csrfToken) {
511
- headers['X-CSRF-Token'] = csrfToken;
610
+ let body;
611
+ if (method === 'GET') {
612
+ // A native GET submit REPLACES the query string with the form data.
613
+ // The previous code built the params and then threw them away, so an
614
+ // enhanced GET form fetched a bare URL with none of its fields.
615
+ action.search = String(entries);
616
+ } else if (multipart) {
617
+ // Hand FormData straight to fetch so the browser writes the boundary.
618
+ body = entries;
619
+ } else {
620
+ body = entries;
621
+ headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
512
622
  }
513
623
 
514
- const response = await fetch(action, {
624
+ const response = await fetch(action.href, {
515
625
  method,
516
- body: method === 'GET' ? undefined : formData,
626
+ body,
517
627
  headers,
628
+ credentials: 'same-origin',
518
629
  });
519
630
 
520
- form.dispatchEvent(new CustomEvent('form:response', {
631
+ const responseEvent = new CustomEvent('form:response', {
521
632
  bubbles: true,
522
- detail: { response, ok: response.ok },
523
- }));
633
+ cancelable: true,
634
+ detail: { response, ok: response.ok, redirected: response.redirected },
635
+ });
636
+ const proceed = form.dispatchEvent(responseEvent);
637
+
638
+ // Same-origin only. A native submit would follow an off-site redirect,
639
+ // but a framework default that can navigate the page to another origin
640
+ // on a server's say-so is not a default worth having. Listen for
641
+ // form:response if you need that.
642
+ if (proceed && response.ok && response.redirected) {
643
+ let target = null;
644
+ try {
645
+ const url = new URL(response.url, location.href);
646
+ if (url.origin === location.origin) target = url.href;
647
+ } catch { /* unparseable: do not navigate */ }
648
+ if (target) location.assign(target);
649
+ }
524
650
  } catch (error) {
525
651
  form.dispatchEvent(new CustomEvent('form:error', {
526
652
  bubbles: true,