You have a GraphQL API on one side and a $mol UI on the other. This repo shows the
shortest path between them: a component describes the data it needs in a .graphql
file next to its code, a code generator turns that file into a typed function, and the
component calls it and renders the result. Every component declares its own data (the
fragment idea, borrowed from Relay), so one request fetches exactly what the page
needs, and $mol re-renders when the data changes. New to $mol and GraphQL? Start with
the walkthrough below: it builds the
whole picture on the demo's real files.
In expert terms: a copy-paste-able starter wiring $mol components to a GraphQL API with full end-to-end typing and Relay-style fragments: declared per component, spread by name, masked for everyone else, zero imports, no changes to the $mol/mam builder.
Live demo: https://trip2g.github.io/mol_graphql/ (runs entirely in the browser: the
$demo_pages entry links the in-browser GraphQL mock into the bundle alongside the
app, no server; likes reset on reload).
docker-compose up --build
# open http://localhost:8080 (mock GraphQL API is on http://localhost:4000/graphql)You get a page that greets the viewer (plain typed query), lists notes (parent query composing a child's fragment), and lets you like a note (typed mutation + reactive refetch), typed from the schema down to the component code.
Two facts are enough to read this section:
- A GraphQL query is a plain-text list of the fields you want; the server answers with JSON of exactly those fields. A fragment is a named, reusable piece of such a list.
- A $mol component is a folder with two files:
*.view.treedeclares what is on screen,*.view.tsadds behavior in TypeScript. A property declared in the tree can be computed or overridden in the.ts.
The app greets the current user. Its data need lives in
app/viewer.graphql, a plain file next to the component:
query {
viewer {
name
pinned_note {
...demo_note_card_note
}
}
}Only name matters for now; pinned_note { ...demo_note_card_note } reuses another
component's fragment, see step 2.
The codegen watches this file and generates app/viewer.graphql.ts next to it: a
typed function $demo_app_viewer, named after the file path. The component just
calls it:
// app/app.view.ts
@ $mol_mem
greeting() {
return `Reading list of ${$demo_app_viewer().viewer.name}`
}No import, no fetch plumbing: $demo_app_viewer() performs the request, and
.viewer.name is typed from the schema, so a typo in a field name is a compile error.
@ $mol_mem memoizes the property and makes it reactive: $mol suspends the
computation while the response is in flight and replays it when the data arrives; when
the data later changes, everything that read it re-renders.
The tree file puts the text on screen:
$demo_app $mol_page
title \$mol × GraphQL fragments
body /
<= Greeting $mol_paragraph
title <= greeting \
That is the whole loop: a .graphql file describes the data, a generated typed
function fetches it, a one-line property renders it.
The page also shows a list of note cards: the card (note/card) is the item, the app
is the catalog. Each declares only what it itself needs.
The card states its data needs as a fragment in
note/card/note.graphql:
fragment demo_note_card_note on Note {
id
title
body
likes
author {
name
}
}The card's tree declares its layout and one input, note_ref:
$demo_note_card $mol_view
note_ref null $demo_note_card_note_ref
sub /
<= Title $mol_paragraph
title <= note_title \
<= Body $mol_paragraph
title <= note_body \
note_ref is not the data but an opaque reference (the $demo_note_card_note_ref
type is also generated from the fragment file). The card turns it into fields with a
generated unmask helper:
// note/card/card.view.ts
@ $mol_mem
note() {
return $demo_note_card_note_unmask_not_null(this.note_ref())
}
note_title() {
return this.note().title
}
like_title() {
return `♥ ${this.note().likes}`
}The card never fetches anything: whoever shows a card hands it a ref, and only the card can open it.
Now the catalog side. The app's page query in app/notes.graphql
spreads the card's fragment by name:
query DemoAppNotes {
notes {
id
...demo_note_card_note
}
}One network request fetches the list ids plus every field every card asked for. The
app itself owns only id: reading notes()[0].title in app code is a compile error,
because the card's fields are masked from everyone else.
The app renders one card per note and passes each card its ref, in the tree:
<= Note_list $mol_list
rows <= note_rows /
Note_card* $demo_note_card
note_ref <= card_ref* null
and in the behavior file:
// app/app.view.ts
@ $mol_mem
notes() {
return $demo_app_notes().notes
}
@ $mol_mem
note_rows() {
return this.notes().map(note => this.Note_card(note.id))
}
card_ref(id: string): $demo_note_card_note_ref {
return this.notes().find(note => note.id === id)!
}Follow the ref: the query result comes out of $demo_app_notes() typed and masked,
note_rows() makes a Note_card per id, the tree binding note_ref <= card_ref*
hands each card its own masked item, and the card's note() unmasks it. The
pinned_note { ...demo_note_card_note } from step 1 is the same card fragment,
reused for the pinned note.
The like button changes data on the server. The mutation is again a plain file next
to the component, note/card/like.graphql:
mutation DemoNoteCardLike($id: ID!) {
note_like(id: $id) {
id
likes
}
}The codegen turns it into the typed function $demo_note_card_like, and the card
calls it from its click handler:
// note/card/card.view.ts
like(next?: Event) {
// calling this auto-reloads all queries on the page
$demo_note_card_like({ id: this.note().id })
}That comment is the whole cache story (the default revalidation: 'all' convention,
detailed below): after any mutation every query on the page refetches, so everything
derived from the data updates with no manual cache work.
The page renders the greeting, the pinned-note line, and one card per note with its title, body, author, and a like button with the live count. Click like: the typed mutation fires, the page queries refetch, and every count on screen updates.
The payoff: the catalog fetches exactly what its cards need in a single request; each card unmasks only its own fields; a mutation refreshes the page by convention; $mol re-renders whatever read the changed data.
The rest of this page is reference: the codegen, what it generates, tuning the refetch scope, testing.
Each component module co-locates its GraphQL operations as separate .graphql files.
A watch codegen (graphql-codegen + ~150 lines of custom preset/plugin) generates a
<name>.graphql.ts next to each: a thin, fully-typed wrapper in the global
namespace $. Two builders meet through a file seam: graphql-codegen writes
.graphql.ts, the $mol builder (mam) compiles it as ordinary module TypeScript,
exactly like .view.tree → -view.tree/*.d.ts in $mol itself. The generated symbol
$<module>_<opname> appears in namespace $ with zero imports.
This repo is the demo package of a mam workspace
(the repo root mounts at demo/), so a repo path app/... is workspace path demo/app/...
and the generated symbols carry the $demo_ prefix.
.graphql source |
generated .graphql.ts |
exported symbol |
|---|---|---|
app/notes.graphql |
app/notes.graphql.ts |
$demo_app_notes(): demo_app_notesQuery |
app/viewer.graphql |
app/viewer.graphql.ts |
$demo_app_viewer(): demo_app_viewerQuery |
note/card/note.graphql |
note/card/note.graphql.ts |
$demo_note_card_note + $demo_note_card_note_ref + $demo_note_card_note_unmask(ref) + $demo_note_card_note_unmask_not_null(ref) |
note/card/like.graphql |
note/card/like.graphql.ts |
$demo_note_card_like(vars): demo_note_card_likeMutation |
The result/variables types are baked in by the generator, so there is no reliance on byte-for-byte string-literal matching.
Write query { ... } (anonymous) or query AnyName { ... }: the codegen rewrites
the operation name to the path-derived canonical, the generated symbol without the
leading $. For note/card/like.graphql the symbol is $demo_note_card_like, so
the document sent is mutation demo_note_card_like(...): the function you call, the
operation name the server sees, and the file path match 1:1.
For free: server logs and APM always get a meaningful operation name, and the usual
graphql-codegen "anonymous operation" error is gone
(app/viewer.graphql is anonymous on purpose). Fragments are NOT
renamed: they are spread by name across files, so a rewrite would break the spread
sites; the codegen only prints a non-blocking warning when a fragment's name deviates
from the same path-derived canonical. This repo names its fragments canonically
(note/card/note.graphql declares demo_note_card_note).
This reproduces the Relay fragment rendering model without React and without its normalized store:
-
A component declares its data needs as a named fragment in its own
.graphqlfile:# note/card/note.graphql fragment demo_note_card_note on Note { id title body likes author { name } }
-
Fragments are global, spread by unique name. Any operation (or another fragment) can spread
...demo_note_card_note, regardless of the component tree. Fragment names follow the same path-derived canonical (note/card/note.graphql->demo_note_card_note), so the file path, which encodes component and prop, makes them globally unique. At codegen time the fragment definitions are merged (transitively) into every operation that spreads them, producing one network request with no runtime document registry:# app/notes.graphql query DemoAppNotes { notes { id ...demo_note_card_note } }
-
Masking. The parent physically receives the fragment's data, but its TYPE hides it.
$demo_app_notes().notes[0]is typed as{ id: string } & { ' $fragmentRefs'?: { demo_note_card_noteFragment } }. Reading.titlein the parent is a compile error:error TS2339: Property 'title' does not exist on type '{ __typename?: "Note"; id: string; } & { ' $fragmentRefs'?: ... }' -
Unmask accessor instead of
useFragment. Relay'suseFragmentis really an identity cast, not a hook, so in $mol it is a plain generated function used inside a reactive$mol_memproperty:// note/card/card.view.ts export class $demo_note_card extends $.$demo_note_card { @ $mol_mem note() { // checked unmask: the parent always binds a real ref (see below) return $demo_note_card_note_unmask_not_null(this.note_ref()) } note_title() { return this.note().title } }
Alongside the fragment type, each fragment file generates a bare-name alias for its opaque ref:
// note/card/note.graphql.ts (generated)
export type $demo_note_card_note = demo_note_card_noteFragment
export type $demo_note_card_note_ref = $demo_graphql_ref<$demo_note_card_note>The alias lets the ref type be named by a bare $name in .view.tree (a tree
property cannot carry a generic), so the card types its input right in the tree, with
no .view.ts override:
$demo_note_card $mol_view
note_ref null $demo_note_card_note_ref
mol properties are always nullable by default, so this generates
note_ref(): $demo_note_card_note_ref | null in the tree .d.ts. The parent binds
the ref as before (note_ref <= card_ref* in app/app.view.tree).
Unmask keeps the schema's nullability. Each fragment generates two accessors; the choice follows the schema:
-
$<name>_unmask(ref)preserves the ref's nullability: a non-null ref yields the fragment, aRef | nullyieldsFrag | null, so the compiler forces the null branch. Use it for anything the schema can null: a nullable field, a null list element. In the demoUser.pinned_noteis nullable and the app shows a fallback:// app/app.view.ts @ $mol_mem pinned() { // Ref | null in, Frag | null out return $demo_note_card_note_unmask($demo_app_viewer().viewer.pinned_note) } pinned_title() { const note = this.pinned() if (!note) return 'No pinned note' // the compiler forces this branch return `Pinned: ${note.title} (♥ ${note.likes})` }
-
$<name>_unmask_not_null(ref)is the checked accessor for values that are non-null by schema but arrive through a nullable seam (a tree-typed property is| nullby default). On a null ref it throws a clear error naming the fragment. The safe replacement for TS!, which has no runtime check:// note/card/card.view.ts: list elements are non-null in the schema @ $mol_mem note() { return $demo_note_card_note_unmask_not_null(this.note_ref()) }
Rule of thumb: nullable by schema, use unmask and handle the null; guaranteed
present, use _unmask_not_null, never !.
Invalidation is one reactive marker (graphql/index.ts): every
query subscribes to a generation counter, every mutation bumps it, so all
$mol_mem-oized queries on the current page re-run.
This fires some duplicate and overlapping requests, deliberately: a redundant fetch is cheaper than a UX bug from a forgotten invalidation callback. Optimize only when a real cost shows up.
Pass { revalidate: false } to $demo_graphql_request to opt one query out
of refetching, or one mutation out of triggering a refetch. On the backend, persisted
queries make the repeated reads cheap. The request layer and the unmask path stay
small and swappable, so smarter per-fragment reactivity via $mol_mem can land later
without touching generated code.
Live: each note card shows a renders counter, plus a revalidate:false static
panel. Like any note: every card's counter ticks together while the static panel
stays put (card.view.ts,
app.view.ts).
$mol_mem deep-compares each result ($mol_compare_deep), so the page does not
re-render a subtree whose data is unchanged, even when a mutation refetched it over
the network. The redundant refetch costs a network roundtrip; the redundant re-render
never happens.
The demo makes this visible: every render region flashes on re-render, like "highlight
updates" in React DevTools. Like a note: the like region flashes while the author
region (author_name() in card.view.ts) stays
calm.
How much a mutation refetches is a compile-time switch. One line in the plugin config
(codegen/graphqlgen.js) selects the invalidation metadata
baked into every generated wrapper:
config: { molRuntime: '$demo_graphql', revalidation: 'all' } // 'all' | 'by_typenames' | 'disable'all(the default, what this demo ships): the convention above, one universal marker.by_typenames: the codegen walks each operation against the schema and bakes the resulting object-type set into the wrapper. A query subscribes to a per-type marker for each type it reads (reads: ['Note', 'User']); a mutation bumps the markers of the types in its response (writes: ['Note']). A like (writesNote) refetches the notes list (readsNote) but not a viewer query (reads onlyUser), no manual{ revalidate: false }needed. The read set comes from the schema walk, not runtime data, so a query whose list came back empty still refetches when the first item appears (urql's document cache, deriving the sets from runtime__typenames, misses that case).disable: wrappers never subscribe and never bump. Queries fetch once and recompute only when their own inputs change.
{ revalidate: false } keeps working in every mode and opts a single call out.
Operations without metadata (hand-written $demo_graphql_request calls) degrade to the
universal marker in any mode, so mixing them with generated wrappers stays safe.
Enable by_typenames when your backend guarantees that every mutation returns, in its
own payload, all the object types it affects: then the computed write set equals the
real one, per-type invalidation is exact, and @touches is not needed. A mutation
whose payload contains no object type at all (a delete returning Boolean) falls back
to the universal marker automatically: unknown effect, assume everything. The
dangerous case is a mutation that returns one type but also changes another, like a
like that also bumps a per-user counter. Either include the changed type in the
payload or declare it:
mutation DemoFixtureTouch($id: ID!) @touches(types: ["User"]) {
note_like(id: $id) { id likes }
}The codegen unions the declared types into writes and strips the directive from the
sent document. The caveat: nothing forces the author of a side-effectful mutation to
remember that declaration, and a forgotten @touches means silently stale data, which
is exactly the "forgotten invalidation callback" the convention exists to prevent.
Hence the safe default stays 'all'.
The other two modes are proven by tests:
graphql/index.test.ts drives wrappers generated in
by_typenames and disable mode (graphql/fixture/) against a
mock transport and asserts the selective refetch, the empty-list case, @touches, and
the no-metadata degradation.
Subscriptions are codegen-TYPED from the schema, same as queries and mutations,
but their RUNTIME is a separate raw SSE host, the same split trip2g uses: a
query is one request returning one value through the sync fiber transport, while
a subscription is a long-lived stream pushing many values. Forcing a stream
through the request seam would give it the wrong shape, so only the TYPE comes
from the codegen (codegen/molplugin.js); the stream
lives in a small hand-written runtime
(graphql/subscription/subscription.ts).
The subscription document is a .graphql file next to the component that
consumes it (note/live/note_liked.graphql),
and the generated wrapper
(note/live/note_liked.graphql.ts) returns
the raw host with the schema-derived result type baked in:
export function $demo_note_live_note_liked(): $demo_graphql_subscription_host<demo_note_live_note_likedSubscription> {
return $demo_graphql_subscription(`subscription demo_note_live_note_liked {
note_liked { id title likes }
}`, undefined) as $demo_graphql_subscription_host<demo_note_live_note_likedSubscription>
}The consumer (note/live/live.view.ts) calls the
wrapper and reads .data()?.note_liked fully typed, so a schema change breaks
the build instead of silently drifting past a hand-written type:
@ $mol_mem
subscription() {
return $demo_note_live_note_liked()
}$demo_graphql_subscription<Data>(query, variables) returns a shared reactive
host per document: .data() is a $mol_mem holding the latest event payload
(typed Data | null), .error() the latest failure, .opened() the connection
state. The Data generic is a compile-time claim only, pinned by the generated
wrapper; the runtime is identical for every caller. Reading .data() spins the
stream up; the host reconnects after a delay when the stream drops, and aborts
the connection when nothing on the page renders the data anymore.
The transport is SSE: the default connect seam POSTs the document to the GraphQL
endpoint with Accept: text/event-stream (the graphql-sse "distinct
connections" protocol) and parses event: next frames. The server side is a
plain async iterator in server/mock.mjs: the like mutation
broadcasts the changed note to every live note_liked stream, and graphql-yoga
turns the iterator into SSE natively, so curl -N against the endpoint shows
the raw events.
The consumer is the watcher idiom from trip2g: the live panel binds a watcher
property in its view.tree body, so rendering subscribes it and every SSE event
re-runs the $mol_mem. The watcher defers its side effect out of the
computation with setTimeout: it calls $demo_graphql_revalidate(), a manual
bump of the universal invalidation markers
(graphql/index.ts), so every page query refetches. A like
made in ANOTHER browser tab therefore updates the note cards live: the
subscription event drives the same revalidation convention as a local mutation.
The connect seam is swappable exactly like the request transport: the Pages
build has no server, so the in-browser mock
(graphql/mock/mock.ts) swaps
$demo_graphql_subscription_connect for a local emitter. The mock like mutation
broadcasts the changed note to every open "stream" on a macrotask, like a real
network push would arrive, so the live panel works with zero network on GitHub
Pages too.
Tests (note/live/live.view.test.ts) swap the
connect seam for a stub that captures the events sink, then push subscription
events by hand: the same global-swap-restore discipline as the transport mock.
In order, from a .graphql file to a running component:
- A component's own operations:
app/notes.graphqlandnote/card/note.graphql. Plain files next to the component. - The codegen that types them:
codegen/molplugin.js:renameOperationsrewrites the operation name to the path-derived canonical;operationCodemerges spread fragments into the sent string and emits the typed wrapper;fragmentCodeemits the fragment type, the ref alias and bothunmaskhelpers;escapeDollarsis the$-escape fix.codegen/preset.jswires one output per file. - The generated output:
app/notes.graphql.ts(masked query with the fragment merged in) andnote/card/note.graphql.ts(fragment type + ref alias + unmask helpers). - The runtime:
graphql/index.ts: the request layer and refetch convention, the invalidation markers, the opaque ref type. - A component consuming a fragment:
note/card/card.view.ts:note()unmasks the ref;renders()is the counter that ticks on every refetch. - The opt-out in action:
app/app.view.ts:viewer_static()passes{ revalidate: false }, so its counter never moves.
The repo is a mam package in the canonical hyoo-ru
shape: no vendored builder bootstrap, just module files. A mam workspace (cloned from
hyoo-ru/mam, which provides mam.ts/tsconfig.json
and the namespace→repo map for mol/node) mounts this repo at demo/. That is what
hyoo-ru/mam_build does in CI with
package: 'demo'.
codegen/
graphqlgen.js graphql-codegen config (checked-in SDL, no introspection)
preset.js one output per .graphql file + shared schema types
molplugin.js typed wrappers + fragment unmask helpers, in namespace $
server/
schema.graphql the SDL: single source of truth for server AND codegen
index.mjs graphql-yoga mock server with in-memory data
graphql/index.ts runtime: request fn, error, reactive marker, ref type
graphql/subscription/ raw SSE subscription runtime: reactive host + connect seam
graphql/mock/ $demo_graphql_mock: in-browser mock; linking it swaps the transport
graphql/schema.graphql.ts (generated) shared scalar/enum/input types
app/ $demo_app: page, plain query + fragment-composing query
pages/ $demo_pages: thin Pages entry, links the app + the mock
note/card/ $demo_note_card: fragment + unmask + typed mutation
note/live/ $demo_note_live: live panel over the note_liked subscription
package.json DEV TOOL only: codegen + mock server (not part of the build)
Local dev happens inside a mam workspace:
git clone https://github.com/hyoo-ru/mam.git mam-ws && cd mam-ws
git clone https://github.com/trip2g/mol_graphql.git demo
npm install| command (from the workspace root) | what |
|---|---|
npm start |
mam dev server on :9080 (/demo/app/); run the mock server alongside |
npx mam demo/app |
one-shot production build into demo/app/-/ (type-checks the bundle, runs tests) |
npx mam demo/pages |
build the serverless Pages entry into demo/pages/-/ |
command (from demo/, this repo) |
what |
|---|---|
docker-compose up --build |
mock GraphQL API on :4000 + built app on :8080 |
npm install && npm run codegen |
regenerate all *.graphql.ts |
npm run codegen:watch |
regenerate on every .graphql/schema change |
npm run server |
run the mock GraphQL server locally |
The dev loop is npm run codegen:watch + npm run server (both in demo/) + npm start
(workspace root) in three terminals. The mam builder picks up regenerated .graphql.ts
like any source change.
deploy.yml is the canonical hyoo-ru pipeline:
hyoo-ru/mam_build@master2 assembles the workspace (clones hyoo-ru/mam + deps,
mounts this repo as package: 'demo'), builds demo/app and demo/pages, runs
every *.test.ts; then the demo/pages/- folder is published to Pages. The
default GITHUB_TOKEN is enough: everything mam_build clones is public. The deployed
site is $demo_pages (pages/pages.ts): a thin entry that renders
the app and explicitly links $demo_graphql_mock
(graphql/mock/mock.ts). That link is the whole transport
choice: the mock module's body swaps the transport seam for a sync in-browser mock
answering each operation from the same dataset as the mock server, while demo/app,
which never references the mock, keeps the server transport. Keep the dataset in sync
with server/mock.mjs by hand.
- Take
codegen/as-is; pointschemaat your SDL file anddocumentsat your UI tree; setmolRuntimeto your prefix (e.g.$myapp_graphql). - Port
graphql/index.tsunder your prefix (request fn, error, ref type, invalidation marker), or swap the body of*_requestfor your transport. - Write
.graphqlfiles next to your components: one operation or fragment per file; file path defines the generated symbol (a/b/c.graphql→$a_b_c) and the operation name (a_b_c, whatever you wrote in the file, even nothing); name fragments by their file path too (samea_b_crule), which is globally unique automatically; the codegen warns if you deviate. npm run codegen:watchnext to your mam dev server. Generated*.graphql.tscan be committed (this repo does) so the app builds without running codegen first.
- $mol module paths are literal:
$demo_note_cardmust live atdemo/note/card/in the workspace, i.e.note/card/in this repo. Underscore = directory separator, always. (The builder resolves dependency FQNs by exact path segments; hence the mount pointdemo, notmol_graphql: no underscores in path segments.) - The $mol dep scanner reads
$-tokens everywhere, including string literals and doc-comments; emitted non-module$is escaped as\u0024. Full story in The$dependency scanner below. - The generated wrapper for an operation that spreads fragments carries a
/** Spreads fragments: $demo_note_card_note */doc-comment: a real dependency edge for the builder (nothing else would link the fragment's module into the bundle, fragments being independent of the view hierarchy). - The app calls
http://localhost:4000/graphql(see$demo_graphql_endpoint, override it for other setups). CORS is open on the mock server.
The $mol builder does not parse code to find a module's dependencies. It scans the
source TEXT for $-prefixed names and resolves each one to a file - everywhere,
including string literals and doc-comments. This repo leans on that deliberately:
the generated /** Spreads fragments: $demo_note_card_note */ doc-comment is a real
dependency edge that links the fragment's module into the bundle. The flip side:
every stray $-token in source text is a build input, intended or not.
GraphQL query variables ($id) and fragment-masking keys (' $fragmentRefs',
' $fragmentName') carry a $ that is not a $mol module; the scanner would resolve
phantom modules and fail the build. So the codegen writes every such $
as \u0024: escapeDollars for type output,
escapeTemplate for the embedded query strings, both in
codegen/molplugin.js. Real module references the codegen
DOES want as dependencies ($demo_note_card_note) stay unescaped on purpose. If you
hand-write such non-module tokens in a module .ts, escape them the same way (this
repo's graphql/index.ts does, for the masking keys).
Prior art avoided the phantom modules with empty stub directories named after the
phantom tokens (fragment/, id/, ...). Escaping is the canonical fix: no stub dirs,
and it survives new field and variable names automatically.
\u0024 is a compile-time unicode escape: TS and JS decode it to U+0024 = $ when
the string literal is parsed, so the generated types and the RUNTIME query strings
are identical in meaning to writing $. The escape exists only in the raw source
text (six ASCII characters, no $ byte) to hide the token from the textual scanner.
At runtime the query string carries a real $: the Like mutation is sent as
mutation demo_note_card_like($id: ID!), a real $ in the POST body, and the
server receives normal GraphQL, never \u0024.
The same rule bites hand-written modules. A comment in
graphql/index.ts once named the in-browser mock module by its
live symbol. Because graphql/index.ts is a real dependency of the app, that one
comment token pulled the whole in-browser mock into the production app bundle: the
app rendered mock data and never called the server. The fix is NOT to escape (a
comment is prose, not code that needs the character) but to DROP the $: the comment
now names the mock in words, "the mock module (graphql/mock/mock.ts)". The flip side
is what the Pages entry does on purpose: $demo_pages_transport = $demo_graphql_mock
is a real code reference, so the mock links into THAT bundle by explicit choice.
The scanner treats every $-token in source text as a dependency. Two tools:
escape (\u0024) tokens that must keep the character but are not modules; name modules
you do not want linked without the $ (in words, by file path).
$mol tests live next to the code: card.view.test.ts,
app.view.test.ts. They use $mol_test + $mol_assert, and
every mam build compiles them into demo/app/-/node.test.js and
runs them; a failing test fails the build. Run just the bundle with
node --enable-source-maps demo/app/-/node.test.js from the workspace root.
GraphQL components are tested with NO server by mocking the transport seam. $demo_graphql_transport
is a namespace export let, so a test reassigns it to a mock that counts calls per operation and
answers from a tiny in-memory store, then restores it in finally:
// app/app.view.test.ts (sketch)
const { calls, transport } = graphql_mock() // per-operation call counter + in-memory store
with_transport(transport, () => {
const app = $demo_app.make({ $ })
$mol_assert_equal(app.greeting(), 'Reading list of Ada Lovelace')
$mol_assert_equal(calls.demo_app_notes, 1)
// mutate, then re-read, then assert the page queries refetched
})What the tests prove:
- Fragment unmask (
card.view.test.ts): a fragment ref unmasks into the typed fields and the card renders them; a null ref stays null throughunmaskand makesunmask_not_nullthrow with the fragment's name. No network. - The nullable field (
app.view.test.ts): a presentpinned_noterenders the pinned panel, a null one renders the fallback text. - The refetch convention (
app.view.test.ts): after a like mutation, the page queries are re-requested (demo_app_notesanddemo_app_viewercall counts go 1 → 2) and the data changes. - The re-render gate (
app.view.test.ts): across a like, the like region's render probe advances while the author region's stays put, in the liked card and in the untouched one:$mol_compare_deepstops the propagation at the unchanged memos. - The opt-out (
app.view.test.ts): a{ revalidate: false }query is fetched exactly once across a mutation, and a{ revalidate: false }mutation leaves the page queries put.
Worth knowing:
- The mock transport is synchronous (same contract as the Pages mock), so cases are plain sync
functions. Reading a
$mol_memin a test just computes it. - Refetch is lazy: a mutation marks the page-query cells stale but nothing recomputes until the next read, so assert query counts AFTER re-reading.
$mol_memskips deep-equal recomputes, so a stateful mock (likes actually increment) is needed for a re-render to be observable.- The per-case isolated
$cannot intercept the transport (free functions read the global$), so the mock is a global swap restored infinally; leak-proof because $mol runs cases sequentially.
- The GraphQL server is graphql-yoga with fixed in-memory data (likes actually increment server-side).
- No persisted queries, no normalized cache (see above). Subscriptions are codegen-typed like queries, but the stream runtime is the raw SSE host, not the request seam.
type-check evidence: the mam build itself type-checks the exact bundle program (its audit fails the build on any TS error). A whole-workspacetsc -p .also drags in mol's unbuilt demo modules and is noisy; the bundle audit is the real gate.
The fragment model here follows Relay's, without its runtime store.
- Relay: Thinking in Relay. Why each component declares its own data as a fragment and composes them.
- Relay: Guided tour, rendering fragments. The exact rendering and masking model this demo reproduces.
- Relay: Thinking in GraphQL. The data-fetching philosophy behind it.
- urql: Document caching. A lighter cache than Relay's normalized store, closer to the "just refetch" choice here.
- Павел Черторогов: ApolloClient или Relay с фрагментами (YouTube, RU). Fragments, "hairy" GraphQL, and TypeScript.