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 | /test → route.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 tokenizesdon'tasdon't(two tokens) or as a singledon't; verify and emit a:do-not-throwflag 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
windowanElinstance (cheap, uniform). - Option B: add a tiny event-target shim on
windowwith 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-fetchtoday unwraps JSON viahs-host-to-sx rawwhenfmt = "json". The new wrapper keeps:_jsonas a raw string; the existingas jsonpath must now parse:_jsonthrough host JSON before callinghs-host-to-sx. Failure mode: doubled parse or raw-string leak into testscan 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 recognisespage.route('**/test', …)and emits a(set-fetch-script! "...")preamble so_driveAsyncpicks up the right response. This is a small generator change, not a deep rewrite. - Request interception for the
beforeFetchtest requireswindow.addEventListeneron 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 (seehs-tokenizer.sx). May require a targeted fix sodon'ttokenises as one word in the fetch-modifier position. - Catchable errors. Hyperscript
catchflow in our runtime currently catches host exceptions; confirm raising a dict via(raise {...})propagates into the catch handler and binds ase. hyperscript:beforeFetchbubble path. Upstream dispatches on the element; the test listener is onwindow. Element→document→window bubbling in our JSDOM-lite is partial; simplest fix is to dispatch on the element and invoke anywindowlisteners for that event type.
7. Implementation checklist (commit-sized)
- Design merge (this doc). One commit, no code.
- Runner:
_fetchScripts+_currentTestNameplumbing. Extendtests/hs-run-filtered.jsonly; add a self-test row in the mock scripts. No generator / runtime changes yet. - Runner:
window.addEventListenershim + dispatch relay from element events. Add a smoke assertion. - Generator: recognise
page.route/route.fulfill/route.abort. Emit(set-fetch-script! …)SX form; remove matched tests fromSKIP_TEST_NAMESincrementally (one per commit below). - Runtime: response wrapper + reshape
hs-fetch. Keep existingas json/as text/naked paths green; un-skip test 2 (as responseon 404) first — smallest surface. Commit:HS: fetch response wrapper preserves status on 404 (+1). - Runtime: chained
as JSONon Response. Un-skip test 1. Commit:HS: as JSON on Response wrapper (+1). - Tokenizer / parser:
do not throwmodifier. Un-skip test 4. Commit:HS: fetch do-not-throw modifier (+1). - Tokenizer:
don't throwcontraction. Un-skip test 5. Commit:HS: fetch dont-throw contraction (+1). - Runtime: throw on non-2xx default. Un-skip test 6. Risk: the
default-behaviour change could break unrelated
fetch /testtests if any current routes are non-2xx. Audit_fetchRoutes. Commit:HS: fetch throws on non-2xx default (+1). - Runtime: raise on network error + runner networkError flag.
Un-skip test 3. Commit:
HS: fetch catch network error (+1). - Runtime: dispatch
hyperscript:beforeFetch. Un-skip test 7. Commit:HS: hyperscript:beforeFetch event (+1). - Sync WASM staging after each runtime change:
cp lib/hyperscript/runtime.sx shared/static/wasm/sx/hs-runtime.sx(per plan rule 4). - 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.