Skip to content

Multimethods#807

Merged
borkdude merged 23 commits intomainfrom
multimethods
Apr 18, 2026
Merged

Multimethods#807
borkdude merged 23 commits intomainfrom
multimethods

Conversation

@borkdude
Copy link
Copy Markdown
Member

No description provided.

borkdude added 23 commits April 18, 2026 12:36
defmulti, defmethod, hierarchy ops (isa?, derive, underive, parents,
ancestors, descendants, make-hierarchy), and method-table ops
(get-method, methods, remove-method, prefer-method, prefers) live in a
new src/squint/multi.js module, imported only when one of those forms
appears in user code — programs that don't use multimethods pay zero
bundle cost.
core.js and multi.js both need the keyword/map-as-fn coercion; instead
of duplicating, move it to a new src/squint/internal.js module clearly
marked as not-public-API, and import from both sides. Bundle cost is
unchanged — programs that pull in core.js already transitively loaded
toFn's logic.
Switch core.js and multi.js to import via relative './internal.js'
paths instead of the bare 'squint-cljs/…' specifier, and drop
internal.js from package.json's exports map. Consumers can no longer
reach it with `import 'squint-cljs/src/squint/internal.js'`, making
the not-public-API intent enforceable rather than aspirational. The
file still ships via `files` so the relative imports resolve inside
installed node_modules.
Users can now extend reporting the same way they would in cljs.test:

  (defmethod cljs.test/report [:cljs.test/default :begin-test-var] [m] ...)

Dispatch is on [*current-reporter* (:type m)], defaulting to the
built-in :cljs.test/default reporter. Unknown combinations fall
through to a no-op :default method so custom reporters don't crash on
events they don't handle.

test-var now also emits :begin-test-var / :end-test-var events
(previously never fired), so reporters can bracket per-var output.
Counter increments move from an unconditional top-of-report block into
the :pass/:fail/:error default methods — matching cljs.test, where
reporters that don't want the default counter behavior can opt out.

Regenerates src/squint/test.js and adds a smoke test exercising the
extension point end-to-end.
Add squint-cljs/src/squint/multi.js to the playground's importmap so
compiled code using defmulti/defmethod can resolve the runtime module,
and extend the bb init copy list to stage multi.js + internal.js
alongside the existing runtime files in public/public/src/squint/.
internal.js is not in the importmap — core.js and multi.js reach it
via a relative import, so it just needs to sit next to them on disk.
The playground's dev-mode rewrite sent 'squint-cljs/core.js' through
/js/squint-local/core.js (the 38-byte root shim that re-exports from
./src/squint/core.js). Vite transformed that shim's relative import
into a /@fs/.../src/squint/core.js URL.

Meanwhile the same import inside already-served runtime files
(test.js, multi.js, etc.) was left as a bare specifier and resolved by
the browser via the importmap to /src/squint/core.js. Two URLs, same
file — and ES-module caching treats them as two separate modules.
Each gets its own _metaSym; with-meta written through the user side
becomes invisible to meta reads inside the runtime, which is why
deftest'd tests surfaced as 'anonymous' in :begin-test-var events.

Rewrite 'squint-cljs/{,src/squint/}X' straight to /src/squint/X so
user code and importmap-resolved imports converge on one URL, one
module instance. Confirmed against a live Vite+Playwright repro:
before, sameModule=false; after, sameModule=true and deftest names
surface correctly end-to-end.
new Map(h.parents) copies the outer Map but shares the inner Set
values, so _deriveInto on the 'next' hierarchy mutates Sets that the
caller's original hierarchy still points at — a silent cross-contamination.

Add a cloneHierarchy helper that rebuilds each field with fresh Sets,
and use it in derive. core.js already has a copy() helper, but it's
shallow in exactly the same way and wouldn't help here. Regression
test in multi_test.cljs.
2-arg derive mutates *global-hierarchy* in place, so MultiFn.getMethod's
identity-based cache check (cachedHierarchy !== hierarchy.deref()) never
fires. A value cached when only one isa match existed keeps resolving
to the stale fn even after a subsequent derive introduces ambiguity.

Test currently fails — fix in a follow-up commit.

Also saves the PR-review follow-up list to todo.md.
Hunting the stale-cache repro surfaced a second bug that was hiding
it: _deriveInto iterated tag's ANCESTORS when propagating the new
relation, so (derive :x :a) followed by (derive :x :b) would also
make :a isa :b. With :a isa :b the ambiguity check in findBest
resolved cleanly ("a dominates b") instead of throwing, masking the
cache issue. Clojure's derive propagates to tag's DESCENDANTS, not
its ancestors.

Two fixes:
- 2-arg derive now rebuilds-and-swaps _globalHierarchy (like 3-arg
  already did). Identity changes, so MultiFn.getMethod's hierarchy
  identity check fires and the cache invalidates after each global
  derive.
- _deriveInto's tagChain now walks h.descendants[tag] (the set the
  new parent chain should apply to) instead of h.ancestors[tag].

Regression tests for both cases.
- #5: defmulti auto-wraps a plain (make-hierarchy) passed as
  :hierarchy. Previously only deref-able refs worked; a direct
  hierarchy object crashed on first dispatch with "deref is not a
  function". A plain hierarchy now gets wrapped into a frozen
  snapshot. Regression test defmulti-accepts-plain-hierarchy-test.
- #6: the global hierarchy moves from a module-local `let` onto
  globalThis under Symbol.for('squint.multi.hierarchy'). If multi.js
  ever loads twice under different URLs (npm + CDN, symlink quirks,
  monorepo dedupe misses — same shape of bug as the playground
  _metaSym episode), all instances now share one hierarchy. Purely
  defensive; no user-facing test since observing it requires reaching
  into internals.
- #3: softened the CHANGELOG zero-cost claim to "programs that use
  neither multimethods nor cljs.test" — test.js transitively pulls in
  multi.js now.
- #4: CHANGELOG now flags the cljs.test/report defn→defmulti change
  as potentially breaking and explains the migration path.

todo.md reorganised: #1/#3/#4/#5/#6 moved to Done, #2 (defmulti redef
in REPL) remains under Defer.
Hoisting _globalHierarchy onto globalThis[Symbol.for(…)] was
over-defensive. The playground bug we used as precedent was fixed by
unifying URLs, not by papering over duplicate loads with shared
state. Normal ESM semantics — one module instance per realm per URL —
is the correct behavior, and every other module-level state in squint
(including core.js's _metaSym) lives that way. cljs.core's
*global-hierarchy* is module-scoped for the same reason.

A user who ends up with two instances of multi.js under different
URLs almost certainly has two versions of squint in their graph;
sharing state across versions via globalThis would hide real
version-mismatch bugs instead of surfacing them.

Move #6 from Done to Wontfix in todo.md with the reasoning.
CI failed on ubuntu-latest (pass locally): global hierarchy is shared
across tests in this ns, and two tests were both deriving :x against
:a and :b. In whatever order shadow-cljs emitted them on CI,
derive-does-not-leak-to-tag-ancestors-test ran first and left :x
isa both :a and :b in the global hierarchy. Then
two-arg-derive-busts-cache-test's first (k :x) — outside its try/catch —
hit the ambiguity immediately → unhandled promise rejection →
process exited non-zero after the summary was already printed.

Switch to namespaced keywords per test (:leak/* and :cache/*) so the
shared global hierarchy doesn't cross-pollinate between tests.
Clojure's signatures are (f tag) and (f h tag) — hierarchy FIRST when
provided. The JS impl had them reversed (x, h), and the existing
hierarchy test wrote (ancestors :a h0) — which happened to match the
buggy shape, so the bug hid behind the test that was supposed to pin
it down. A caller following the Clojure signature (ancestors h :foo)
would compile to ancestors(h, :foo) — the impl would then try
":foo".parents and crash.

- Extract a hAnd(a, b, field) helper that dispatches on arity: 1-arg
  reads the global hierarchy, 2-arg puts the passed hierarchy first.
- Rewrite the existing hierarchy-accessor call sites in the immutable
  3-arg derive test to the correct (h tag) form.
- Add hierarchy-accessors-follow-clojure-signature-test pinning both
  arities with intentionally unambiguous expectations.
preferTable, ancestors, parents, and descendants are all Map<any, Set<any>>
with dispatch values as keys — frequently vectors like [:km :m] that
get freshly constructed by the caller. The existing impl used JS
reference equality (Map.get / Set.has), so:

- prefer-method with a freshly-read vector pair wouldn't see the
  earlier preference, and the cycle check wouldn't fire.
- isa? on the same vector from two different call sites disagreed.
- derive would create duplicate parent entries for the same tag if
  called twice with fresh vectors.

addRel, _prefers, _isa, and _deriveInto now route their key/member
lookups through findKeyByEquiv / setHasEquiv so value-equality decides
map membership. isPrimitive + findKeyByEquiv move up near the other
helpers and get a setHasEquiv sibling.

Regression test prefer-method-vector-dispatch-equiv-test covers the
cycle-detection and read-back paths.
Previously methodCache was populated only for primitive dispatch
values (isPrimitive guard). Every vector dispatch — the common
(defmulti f (fn [a b] [a b])) shape — re-ran findBest from scratch,
which is O(methods × _isa-cost) per call. For workloads where the
same [:from :to] tuple dispatches repeatedly, that work was pure
waste.

Split the read path in two: primitives stay on Map.get (O(1));
non-primitives scan the cache with _EQ_ so freshly-allocated
structurally-equal vectors hit prior entries. Cache writes run
unconditionally now, so the miss path populates the cache for any
dispatch value.

Deliberately not using JSON.stringify canonicalization — it collapses
symbol/bigint/undefined/NaN to identical strings (correctness bug),
and native Map keying via _EQ_ handles every primitive type correctly
by language semantics. A trie or typed canonicalization is the next
step if the linear scan becomes a bottleneck in practice.
…export

The cycle between core.js (imports toFn) and internal.js (imports get)
was safe today — ES modules handle cycles via hoisted function
bindings, and `get` was only referenced from inside closures, not at
top level — but the reviewer's concern was pattern hygiene: any future
top-level reference on either side would TDZ at load time.

Rather than invert the dep tree (which would mean moving get and its
transitive deps List/LazyIterable/SortedSet into internal.js), just
fold toFn back into core.js, renamed to __toFn so the double-underscore
prefix signals "implementation-helper, not public API". multi.js
imports it from 'squint-cljs/core.js' alongside _EQ_.

Update bump-core-vars to exclude __-prefixed names so the compiler
doesn't resolve a bare (__toFn …) in user CLJS as a core var. Names
with __ embedded (IIterable__iterator, _GT__EQ_, clj__GT_js, …) are
unaffected because the filter only matches the prefix.

Drops:
- src/squint/internal.js
- its entries in package.json "files" and playground/bb.edn
The duck-typed .get fallback in core/get checked g via `instanceof
Function`, which fails cross-realm — a function from a different
realm (iframe, vm sandbox) is NOT an instance of the current realm's
Function constructor. typeof is a language-level operator that works
regardless of origin realm, and also skips the prototype-chain walk.

Only one live instanceof Function remained (__toFn was already on
typeof). Commented-out arg-validation block in map/1 left as-is since
it's inert.
getMethod's no-match branch called findKeyByEquiv(methodTable,
defaultDispatchVal) on every dispatch that fell through to the default
— even though defaultDispatchVal is immutable after construction and
the resolved fn only changes when methodTable does.

resetCache already runs on every methodTable mutation (addMethod,
removeMethod, preferMethod), so memoize the resolved default fn there
once and have getMethod just return this.defaultFn. No-match path is
now O(1) instead of O(methods) per dispatch.
Round-2 review caught that hAnd — the reader behind parents /
ancestors / descendants — was doing raw h[field].get(tag) while the
rest of multi.js had been switched to findKeyByEquiv. Result:
(derive h [:km :m] :length) followed by (parents h [:km :m]) returned
nil, even though (isa? h [:km :m] :length) saw the relation.

Route hAnd's lookup through findKeyByEquiv so a freshly-allocated
vector tag reads back the structurally-equal stored entry.

hierarchy-accessors-equiv-on-vector-tags-test pins all four
(isa?, parents, ancestors, descendants) of the reader paths with
fresh-identity vectors. Uses `some` + explicit `=` instead of
`contains?` because squint Sets are reference-equal for non-primitives
— orthogonal gap worth noting but not in scope for this PR.
Everything in the file is either done, rejected in this PR, or a
gap captured in commit messages / code comments / CHANGELOG. The one
deferred item (defmulti redef wipes methods in REPL) is speculative —
if a real user hits it we'll file a proper issue with context.
@borkdude borkdude merged commit 418a601 into main Apr 18, 2026
6 checks passed
@borkdude borkdude deleted the multimethods branch April 18, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant