Squint ClojureScript Dialect

repository·main·Indexed 21 days ago

https://github.com/squint-cljs/squint

A lightweight ClojureScript dialect designed to target JavaScript with minimal bundle size and high performance. Squint uses native JS data structures and is optimized for environments such as Node.js and Cloudflare Workers. It supports integration with Vite, Expo React Native, and various frontend libraries like Preact, SolidJS, and Jotai.

Tokens
32.5K
Snippets
135
Records
169
Agent score
75%

What's inside Squint

  1. Implement value-semantic composite map keys with EncMap

    main

    In JavaScript, standard objects only support string or number keys, and native Map objects compare objects by reference rather than value. To use composite types (vectors, lists, nested maps, sets) as keys where equality is determined by content (value-semantics), use the EncMap pattern.

    EncMap works by encoding the composite key into a canonical string using a custom encoder and using that string as the key in a native js/Map. The value stored in the native map is an array [origKey, val], which allows for efficient iteration without decoding the keys.

    class EncMap {
      constructor(enc) { 
        this.m = new Map(); 
        this.enc = enc || encKey; 
      }
      set(k, v) { 
        this.m.set(this.enc(k), [k, v]); 
        return this; 
      }
      get(k) { 
        const e = this.m.get(this.enc(k)); 
        return e === undefined ? undefined : e[1]; 
      }
      has(k) { 
        return this.m.has(this.enc(k)); 
      }
      *keys() { 
        for (const e of this.m.values()) yield e[0]; 
      }
    }
  2. Understand the shipped scope of {:squint/compile-time true} namespaces

    main

    When a namespace is flagged with {:squint/compile-time true}, it is treated as a compile-time-only namespace. This means only the compile-time parts are loaded into SCI (the compiler's runtime environment).

    What is loaded:

    • Top-level defmacro forms that the Squint reader can see.
    • Forms explicitly marked with ^:squint/compile-time (including those inside #?(:clj ...) branches).

    How it works:

    • The compiler extracts the source for these forms, resolving syntax-quotes and stripping out :clj conditionals so the extracted source is clean.
    • The target compiler strips any forms marked with ^:squint/compile-time (when the marker is true) and does not emit any runtime exports, variables, or :refer imports for macros.
    • Dependencies required by the flagged namespace are carried into the extracted source as :as-alias. This allows aliased references (e.g., str/join) to work during expansion without actually loading the library into the SCI runtime.

    Requirements for expansion: Any symbol called during macro expansion must already be resident in SCI. This includes:

    • Built-ins (e.g., clojure.string).
    • Shims (e.g., cljs.test, cljs.analyzer.api).
    • Helpers within the same namespace that are also marked as compile-time.
  3. How lazy sequences work in Squint

    main

    In Squint, lazy sequences behave like ClojureScript. They are designed to satisfy three constraints:

    1. Cached: Traversing the same sequence more than once computes each element only once.
    2. Streaming: A single forward pass over a large or infinite sequence that is not retained runs in roughly constant memory.
    3. Fast: Per-element overhead is low enough for high-performance pipelines.

    Implementation-wise, the cache is a linked structure of cells. While a native JS iterator is single-shot, Squint's LazySeq uses a cursor that walks these cells. The first pass forces the cells and caches their contents; a second pass starts a fresh cursor at the head and reads the already-realized cached chunks without touching the underlying iterator.

  4. Understand JS interop behavior with keyword keys in object literals

    main

    When using Squint, object literals defined with #js/Map or standard JS object syntax can hold keywords as keys. However, when performing raw JavaScript interop (e.g., calling .get() on a native JS Map), you must be aware that the key type matters. A raw .get("a") call using a string will miss a key that was inserted as a keyword. The caller is responsible for ensuring the key type matches the one used during insertion.

    // If a key is inserted as a keyword, raw JS string access will fail
    // Example of the mismatch risk:
    const m = #js/Map {:a 1};
    
    // This works in Squint/CLJS context because it uses the keyword
    m.get(:a); 
    
    // This fails in raw JS interop because "a" is a string, not a keyword
    // The caller owns the responsibility of using the correct key type
    m.get("a"); 
  5. Understand Squint keyword representation

    main

    In Squint, keywords are currently represented as plain strings. For example, the keyword :foo/bar is internally the string "foo/bar".

    Implications for developers:

    • (keyword? :foo) is not a meaningful check because keywords are strings.
    • Keywords cannot be distinguished from strings in equality checks, sets, js/Map probes, or case dispatch. For example, (= :a "a") evaluates to true.
    • Printing a keyword results in its string representation (e.g., :a prints as "a").
  6. Encoder correctness invariants for composite keys

    main

    When implementing or using custom encoders for composite keys, the following invariants must hold to ensure value-semantic equality matches dequal behavior:

    • Vector equality: encKey([1,2]) === encKey([1,2])
    • LazySeq/List compatibility: encKey([1,2]) === encKey(gen 1,2) (vectors and lazy sequences with same elements are equal)
    • Order sensitivity: encKey([1,2]) !== encKey([2,1]) (order matters for vectors)
    • Type safety: encKey(['12']) !== encKey([1,2]) (prevents string/number collisions)
    • Unordered key canonicalization: encKey({a,b}) === encKey({b,a}) (maps/sets must be sorted during encoding to ensure consistent keys)
  7. Understand Squint's data model differences from ClojureScript

    main

    When porting ClojureScript code to Squint, be aware of how certain data structures behave due to their mapping to JavaScript:

    • Maps as Functions: In CLJS, maps can be used as functions (e.g., (next-player p)). In Squint, maps are plain JS objects, so you must use get instead (e.g., (get next-player p)).
    • Sets: Squint sets compare by reference. If you are comparing a set of vectors (like [y x]) against a path, use = instead of the standard CLJS set equality logic.
    • Vector Keys in Maps: Maps keyed by vectors (e.g., [y x]) are supported. Squint stringifies these keys consistently, ensuring that assoc-in and get-in operations round-trip correctly.
  8. Understand Squint tree-shaking and dead-code elimination

    main

    Squint's JavaScript output is designed to be tree-shakeable by modern bundlers like esbuild, rollup, or vite.

    Import Styles and Tree-shaking

    • Production Builds (vite build): The compiler emits static namespace imports (import * as squint_core from 'squint-cljs/core.js'). These are highly tree-shakeable.
    • Development/REPL Mode: The compiler emits dynamic imports (await import('squint-cljs/core.js')). Dynamic imports are not tree-shakeable, meaning the entire module is retained. This is intentional for development speed and is not representative of production bundle sizes.

    The ~5.4KB Floor (Historical Context)

    Previously, importing even a minimal function like identity would drag in ~5.4KB of core logic. This was caused by:

    1. Top-level protocol mutations: Side-effecting assignments to prototypes.
    2. Computed Symbol-key class members: Classes using [Symbol.iterator] or other non-literal Symbol keys were treated as side-effecting by bundlers and could not be eliminated.

    Current Status: These issues have been resolved using internal @__NO_SIDE_EFFECTS__ wrappers (defclass and withApply), allowing the core to be significantly more granular. A minimal app using only atom now only pulls ~1.3KB instead of ~5.8KB.

  9. Understand cljs mode semantics and behavior

    main

    When :squint/dialect :cljs is active, the language semantics shift to match ClojureScript more closely:

    • Keywords: Literals compile to globally interned Keyword objects. (= :a "a") is false. (str :foo) returns ":foo".
    • Collections: Map, vector, and set literals compile to persistent data structures. To use native JS arrays or objects, use the #js escape hatch.
    • Keyword Invocation: Supports (:foo m) and (:foo m nf) syntax.
    • Equality: Since keywords are interned, === and case statements work correctly for keyword comparison.

    Warning on Interop: In cljs mode, keywords never coerce to property names on JS objects. (get js-obj :foo) will return nil. To access a JS property using a keyword, you must explicitly convert it: (get js-obj (name :foo)) or use js->clj.

  10. How sequences and iterables work in Squint

    main

    Squint uses JavaScript iteration protocols. Most functions like first, rest, map, and reduce call iterable on the collection before processing.

    • seq: Returns an Iterable of the collection, or nil if empty.
    • iterable: Returns an Iterable, even if empty.
    • seqable?: Checks if a collection is iterable.

    Note on Memory: Lazy seq function results hold onto their input. To allow garbage collection of large inputs, convert results to arrays when leaving scope.

    (defn doit []
      (let [x [(-> (new Array 10000000)
                   (.fill 0)) :foo :bar]
            ;; Big array `x` is still being held on to by `y`:
            y (rest x)]
        (println (js/process.memoryUsage))
        (vec y)))
  11. Understand variadic function performance and the fixed-arity solution

    main

    Some core functions in Squint, such as min, max, and conj, are variadic and use the (...xs) rest parameter syntax. This causes a rest array to be allocated on every call, which can lead to performance regressions in hot loops (e.g., in VDOM patching).

    To resolve this, the project is moving toward a compiler-level solution: emitting direct fixed-arity calls (similar to ClojureScript's .cljs$core$IFn$_invoke$arity$2 style). This avoids the overhead of rest array allocation for common arities. If you encounter performance issues with variadic functions in high-frequency loops, consider using the native JavaScript equivalent (e.g., js/Math.min) as a temporary workaround.

  12. Mutability and Invariants in Lazy Sequences

    main

    Squint vectors are mutable JS arrays, and cells cache their chunk arrays. To maintain sequence integrity, the following invariants must be respected:

    1. No Aliasing: A cached chunk must never alias an array that the caller still holds. If a caller mutates that array, it would change the sequence after the fact.
    2. Fresh Arrays: Chunk-aware operations must build a fresh output array per chunk; they must never mutate an input chunk.
    3. concat Behavior: concat copies array collections into its chunks using slice instead of sharing them. This ensures mutating the input after concat is not observable on the resulting sequence.
    4. The vec/doall Exception: vec and doall return the caller's array unchanged. This is a documented Squint behavior where the result aliases the input by design.