Files
rose-ash/plans/designs/e40-real-fetch.md
2026-04-24 07:08:02 +00:00

13 KiB

E40 — Fetch non-2xx + hyperscript:beforeFetch + real Response

Cluster 40 of plans/hs-conformance-to-100.md. Target: +7 tests.

1. Failing tests

All seven currently emit SKIP (skip-list) from the generator (SKIP_TEST_NAMES in tests/playwright/generate-sx-tests.py lines 151-158). Upstream bodies — stripped to assertions:

# Name Mock Script Assertion
1 Response can be converted to JSON via as JSON /test → 200, JSON {name:"Joe"} fetch /test as Response then put (it as JSON).name into me text = "Joe"
2 as response does not throw on 404 /test → 404, body not found fetch /test as response then put it.status into me text = "404"
3 can catch an error that occurs when using fetch /testroute.abort() (network error) fetch /test catch e log e put "yay" into me text = "yay"
4 do not throw passes through 404 response /test → 404, body the body fetch /test do not throw then put it into me text = "the body"
5 don't throw passes through 404 response idem, apostrophe form fetch /test don't throw then put it into me text = "the body"
6 throws on non-2xx response by default /test → 404, body not found fetch /test catch e put "caught" into me text = "caught"
7 triggers an event just before fetching /test → 200, yay; listener on hyperscript:beforeFetch sets target class to foo-set on click fetch "/test" then put it into my.innerHTML end initial: no foo-set; after click: has foo-set AND text yay

2. Upstream semantics

hyperscript:beforeFetch — dispatched on the element running the feature, just before window.fetch() is called. Upstream CustomEvent detail: { resolve, reject, url, options, response? } — handlers may mutate options, call resolve(value) to short-circuit, or reject(err) to fail. The test only checks that the event fires on the element; detail fields are not asserted. We implement the event but only cancelable + mutable options matter for conformance.

Non-2xx handling — upstream default is: when response.ok === false, the command throws FetchError carrying the response. Two opt-outs:

  • as response / as Response — yields the Response wrapper; no throw.
  • do not throw / don't throw — after the conversion, if there was an HTTP error, still return the converted body (whichever format was chosen).

Network errors (route abort, DNS fail) — always thrown regardless of do not throw. catch e handles them.

Response API tests rely on: .ok, .status, .url, and chained conversion(it as JSON) when it is a Response parses the body as JSON. Upstream fetch /test as Response then … (it as JSON) reuses the Response's body; our wrapper must remember the raw body.

3. Proposed runtime shape

3a. Response wrapper (SX dict, not host Response)

{ :_hs-response true
  :ok       <bool>
  :status   <int>
  :url      <string>
  :headers  <dict>
  :_body    <string>      ;; raw text, always kept
  :_json    <string|nil>  ;; raw JSON string (for re-parse on `as JSON`)
  :_html    <string|nil> }

A single dict representation is enough; the kernel already passes dicts through as hs values. No new FFI type.

3b. hs-fetch restructured

Pseudocode (design only — no emission here):

(define hs-fetch
  (fn (url format opts do-not-throw target)
    ;; 1. Dispatch beforeFetch event on `target` element.
    ;; 2. If event.defaultPrevented → return nil.
    ;; 3. Call (perform (io-fetch url "response" opts)) — always fetch
    ;;    the full wrapper, regardless of `format`.
    ;; 4. If the host op returned a :_network-error key → raise a
    ;;    FetchError (catchable by hyperscript `catch`).
    ;; 5. If (not :ok) and (not do-not-throw) and format ≠ "response" →
    ;;    raise FetchError carrying the wrapper.
    ;; 6. Convert by format:
    ;;       "response" → return the wrapper as-is
    ;;       "json"     → (host-call JSON "parse" (:_json wrapper)) → hs-host-to-sx
    ;;       "html"     → io-parse-html on (:_html wrapper) → fragment
    ;;       "text"/other → (:_body wrapper)
    ;;    `do not throw` only matters if (not :ok); the value is still
    ;;    whatever the conversion yields for the real body.))

3c. Chained as JSON on a Response (test 1)

(it as Response) as JSON — hyperscript's as operator needs a Response-aware branch: if (dict? x) and (get x :_hs-response) and target type is JSON, call host JSON.parse on :_json. Add a clause to the existing as / conversion dispatcher in runtime.sx.

3d. Error propagation

Introduce hs-fetch-error — a dict {:_hs-error :FetchError :response <w> :message "..."}. Hyperscript catch already binds the raised value to the catch symbol; tests can catch … and throws on non-2xx … both just put "yay" into me / "caught" so the error's shape is uninspected.

The compiler / parser need two keyword paths:

  • do not throw / don't throw — tokenizer already tokenizes don't as don 't (two tokens) or as a single don't; verify and emit a :do-not-throw flag into the fetch AST.
  • as Response / as response — already handled (runtime line 763).

3e. beforeFetch event

Use the existing DOM event plumbing. Add a primitive call: (host-new "CustomEvent" "hyperscript:beforeFetch" {:detail {...} :bubbles true :cancelable true}), then dispatchEvent on the fetch target. Listen for defaultPrevented before proceeding.

4. Test mock strategy

Stay in-process. Extend tests/hs-run-filtered.js:

4a. Test-name-keyed route table

Add a _fetchScripts map keyed by test name. The runner already tracks _current-test-name (used for the ask/answer cluster, cluster 28). Each entry describes the /test route for that test:

const _fetchScripts = {
  "as response does not throw on 404":
      { "/test": { status: 404, body: "not found" } },
  "do not throw passes through 404 response":
      { "/test": { status: 404, body: "the body" } },
  "don't throw passes through 404 response":
      { "/test": { status: 404, body: "the body" } },
  "throws on non-2xx response by default":
      { "/test": { status: 404, body: "not found" } },
  "Response can be converted to JSON via as JSON":
      { "/test": { status: 200, body: '{"name":"Joe"}',
                   contentType: "application/json" } },
  "can catch an error that occurs when using fetch":
      { "/test": { networkError: true } },
  "triggers an event just before fetching":
      { "/test": { status: 200, body: "yay",
                   contentType: "text/html" } },
};

_mockFetch(url) / the io-fetch branch of _driveAsync reads _fetchScripts[_currentTestName]?.[url] first, falls back to _fetchRoutes[url]. Network-error entries set networkError: true → the io-fetch handler resumes with { _network_error: true, message: "aborted" } so hs-fetch can raise.

4b. window.addEventListener('hyperscript:beforeFetch', …) support

The JSDOM-lite El in the runner already implements addEventListener/dispatchEvent on elements. window in the runner is a plain object — we need a minimal window.addEventListener that records listeners and fires when hs-fetch dispatches the event. Either:

  • Option A: make window an El instance (cheap, uniform).
  • Option B: add a tiny event-target shim on window with the usual methods.

Option A is smaller. The test-7 listener is registered on window, but since bubbles: true, we can also dispatch on document.body if window routing is awkward; upstream dispatches on the element and bubbles.

4c. No real HTTP

All fetches remain synchronous-resume via _driveAsync. Zero network.

5. Test delta estimate (per-test)

All seven are currently SKIP and count zero. Converting them means (a) removing from SKIP_TEST_NAMES, (b) teaching the generator to emit them, (c) runtime + mock changes. Each is mechanically independent:

# Test Risk Est
1 Response → JSON chain Compiler: chained as; runtime: :_json re-parse +1
2 as response on 404 Runtime: don't throw when format=response +1
3 catch abort Runtime: raise on network error; mock: networkError flag +1
4 do not throw + 404 Tokenizer/parser: multi-word modifier; runtime: flag +1
5 don't throw + 404 Tokenizer: apostrophe contraction accepted +1
6 non-2xx throws default Runtime: throw when !ok && !format=response && !do-not-throw +1
7 beforeFetch event Runtime: dispatch event on target; runner: window listener +1

Total: +7. Plus possible knock-on fixes in neighbouring non-skipped tests — the existing "can do a simple fetch with a response object" test (line 7177) currently passes with a thin mock; the restructure must keep it green.

6. Risks

  • Cluster-1 JSON unwrap collision. hs-fetch today unwraps JSON via hs-host-to-sx raw when fmt = "json". The new wrapper keeps :_json as a raw string; the existing as json path must now parse :_json through host JSON before calling hs-host-to-sx. Failure mode: doubled parse or raw-string leak into tests can do a simple fetch w/ json* (currently green — regression risk).
  • Generator coverage. Five of the seven upstream bodies use page.route/route.fulfill/route.abort — the generator currently gives up on these. Need a new translator step that recognises page.route('**/test', …) and emits a (set-fetch-script! "...") preamble so _driveAsync picks up the right response. This is a small generator change, not a deep rewrite.
  • Request interception for the beforeFetch test requires window.addEventListener on a JSDOM-lite host — new surface; keep the shim minimal (addEventListener/removeEventListener/dispatchEvent only).
  • Tokenizer for don't. Apostrophe handling in the HS tokenizer is fragile (see hs-tokenizer.sx). May require a targeted fix so don't tokenises as one word in the fetch-modifier position.
  • Catchable errors. Hyperscript catch flow in our runtime currently catches host exceptions; confirm raising a dict via (raise {...}) propagates into the catch handler and binds as e.
  • hyperscript:beforeFetch bubble path. Upstream dispatches on the element; the test listener is on window. Element→document→window bubbling in our JSDOM-lite is partial; simplest fix is to dispatch on the element and invoke any window listeners for that event type.

7. Implementation checklist (commit-sized)

  1. Design merge (this doc). One commit, no code.
  2. Runner: _fetchScripts + _currentTestName plumbing. Extend tests/hs-run-filtered.js only; add a self-test row in the mock scripts. No generator / runtime changes yet.
  3. Runner: window.addEventListener shim + dispatch relay from element events. Add a smoke assertion.
  4. Generator: recognise page.route / route.fulfill / route.abort. Emit (set-fetch-script! …) SX form; remove matched tests from SKIP_TEST_NAMES incrementally (one per commit below).
  5. Runtime: response wrapper + reshape hs-fetch. Keep existing as json/as text/naked paths green; un-skip test 2 (as response on 404) first — smallest surface. Commit: HS: fetch response wrapper preserves status on 404 (+1).
  6. Runtime: chained as JSON on Response. Un-skip test 1. Commit: HS: as JSON on Response wrapper (+1).
  7. Tokenizer / parser: do not throw modifier. Un-skip test 4. Commit: HS: fetch do-not-throw modifier (+1).
  8. Tokenizer: don't throw contraction. Un-skip test 5. Commit: HS: fetch dont-throw contraction (+1).
  9. Runtime: throw on non-2xx default. Un-skip test 6. Risk: the default-behaviour change could break unrelated fetch /test tests if any current routes are non-2xx. Audit _fetchRoutes. Commit: HS: fetch throws on non-2xx default (+1).
  10. Runtime: raise on network error + runner networkError flag. Un-skip test 3. Commit: HS: fetch catch network error (+1).
  11. Runtime: dispatch hyperscript:beforeFetch. Un-skip test 7. Commit: HS: hyperscript:beforeFetch event (+1).
  12. Sync WASM staging after each runtime change: cp lib/hyperscript/runtime.sx shared/static/wasm/sx/hs-runtime.sx (per plan rule 4).
  13. Scoreboard + plan log in the same commit as each code commit (plan rule 11).

Total commits: 1 design + 2 infrastructure + 7 test-unlocks + 1 wrap-up scoreboard summary = ~10-11 commits. Each unlock is independently committable and safely revertible.