@quillmark/wasm 0.97.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,16 +10,16 @@ Use Quillmark in browsers/Node.js with explicit in-memory trees (`Map<string, Ui
10
10
 
11
11
  The package exposes **one import surface**:
12
12
 
13
- - `@quillmark/wasm` (the root) the **canonical API**: `Quill`, `Document`, and
13
+ - `@quillmark/wasm` (the root), the **canonical API**: `Quill`, `Document`, and
14
14
  an `Engine` that renders them.
15
15
 
16
16
  `Quill` and `Document` are re-exported verbatim from the internal Typst-less
17
17
  core build, so editor/validation code (`Quill.fromTree`,
18
- `Document.fromMarkdown`) loads only that small core binary no backend is
18
+ `Document.fromMarkdown`) loads only that small core binary: no backend is
19
19
  loaded until you render. The `Engine` hides everything else: each backend
20
20
  (`typst`, `pdfform`) is a separate, private WASM binary with its own linear
21
21
  memory, lazily loaded on the first render. The Engine clones a `Quill` /
22
- `Document` into the backend's memory as data and frees the clones you never
22
+ `Document` into the backend's memory as data and frees the clones: you never
23
23
  hold a backend object or cross a memory boundary yourself.
24
24
 
25
25
  ## Build
@@ -28,8 +28,8 @@ hold a backend object or cross a memory boundary yourself.
28
28
  bash scripts/build-wasm.sh
29
29
  ```
30
30
 
31
- The script builds three variants the core (no backend), the Typst backend
32
- (default features), and the Typst-free pdfform backend (`pdfform` feature)
31
+ The script builds three variants: the core (no backend), the Typst backend
32
+ (default features), and the Typst-free pdfform backend (`pdfform` feature):
33
33
  each with `--target bundler` and `--weak-refs` enabled (see
34
34
  [Lifecycle](#lifecycle)).
35
35
 
@@ -66,7 +66,7 @@ const result = await engine.render(quill, parsed, { format: "pdf" });
66
66
 
67
67
  ### `new Engine(options?)`
68
68
  Create the render dispatcher. Routes each quill to its backend by
69
- `quill.backendId`, lazily loads that backend binary, and renders cloning the
69
+ `quill.backendId`, lazily loads that backend binary, and renders: cloning the
70
70
  quill/document into the backend's memory and freeing the clones internally.
71
71
  `render`, `open`, `supportedFormats`, and `supportsCanvas` are **async** (the
72
72
  first call may load a backend). Pass `{ backends }` to register or override
@@ -78,16 +78,16 @@ the backend id.
78
78
 
79
79
  **Capability probes are always free.** `supportedFormats` and `supportsCanvas`
80
80
  depend only on `quill.backendId`, and answer from the descriptor's required
81
- `formats`/`canvas` manifest never loading the multi-MB backend binary and
81
+ `formats`/`canvas` manifest: never loading the multi-MB backend binary and
82
82
  never cloning the quill. Use them as non-failing pre-render probes.
83
83
 
84
84
  ### `Quill.fromTree(tree)`
85
- Build + validate a `Quill` from an in-memory tree. Pure the declared backend
85
+ Build + validate a `Quill` from an in-memory tree. Pure: the declared backend
86
86
  is resolved at render time, not here. Loads no backend binary.
87
87
 
88
88
  ### `new Document(quillRef)`
89
89
  A blank document: a main card carrying only `$quill`, an empty body, and no
90
- composable cards the programmatic blank canvas. Absent fields resolve at
90
+ composable cards: the programmatic blank canvas. Absent fields resolve at
91
91
  render time (schema `default`, else type-empty zero), so nothing the caller
92
92
  did not set reaches the output. Build it up with `storeFields` / `insertCard`.
93
93
  For an example-filled starter use `quill.seedDocument()`. Throws on an
@@ -96,19 +96,19 @@ invalid quill reference.
96
96
  ### `Document.fromMarkdown(markdown)`
97
97
  Parse markdown to a parsed document. Throws a JS `Error` (with `.diagnostics`
98
98
  attached, see [Errors](#errors)) on any parse failure, including a missing
99
- root `$quill` metadata line, malformed YAML, and inputs over the 10 MB
99
+ root `$quill` metadata line, malformed YAML, and inputs over the 10 MiB
100
100
  `parse::input_too_large` limit.
101
101
 
102
102
  ### `doc.toMarkdown()`
103
103
  Emit canonical Quillmark Markdown. Type-fidelity round-trip safe:
104
104
  `Document.fromMarkdown(doc.toMarkdown())` returns a document equal to `doc`
105
105
  under [`doc.equals`](#docequalsother). The output is **not** guaranteed
106
- byte-equal to the original source YAML quoting, key ordering, and
106
+ byte-equal to the original source: YAML quoting, key ordering, and
107
107
  whitespace are normalised. Use `equals` (not string comparison) to test
108
108
  semantic equality.
109
109
 
110
110
  ### `doc.toJson()`
111
- Serialize the document to a versioned storage DTO a JSON **string**
111
+ Serialize the document to a versioned storage DTO: a JSON **string**
112
112
  carrying a `schema` version. Use this (not `toMarkdown`) to persist a
113
113
  document across a process restart or crate upgrade: the wire format is
114
114
  frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
@@ -116,11 +116,11 @@ frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
116
116
 
117
117
  The string is produced inside the module by `serde_json`; the JS `JSON`
118
118
  global is not involved. It is standard JSON text, so callers may
119
- `JSON.parse` it to inspect it but it is intended as an opaque blob you
119
+ `JSON.parse` it to inspect it, but it is intended as an opaque blob you
120
120
  persist and hand back.
121
121
 
122
122
  `toJson()` is **deterministic**: a `Document` that is `equals` to another
123
- serializes to a byte-identical string across repeated calls, and across
123
+ serializes to a byte-identical string: across repeated calls, and across
124
124
  any crate upgrade that keeps the same `schema` version (every release does until
125
125
  the `Document` model changes; see [Storage compatibility](#storage-compatibility-across-versions)).
126
126
  Field order is fixed and object key order is preserved, so content hashes
@@ -145,7 +145,7 @@ not a valid storage DTO. Use it to branch on format without a heuristic or
145
145
  `try`/`catch` as control flow:
146
146
 
147
147
  ```ts
148
- // "JSON canonical, Markdown fallback" no exceptions, no string sniffing
148
+ // "JSON canonical, Markdown fallback": no exceptions, no string sniffing
149
149
  const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
150
150
  ```
151
151
 
@@ -156,14 +156,14 @@ genuinely malformed Markdown.
156
156
 
157
157
  The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
158
158
  not the running crate version. It is a hand-set constant, bumped only when
159
- the `Document` model itself changes so every `0.93.x` patch release reads
159
+ the `Document` model itself changes, so every `0.93.x` patch release reads
160
160
  and writes that same value.
161
161
 
162
162
  - **Upgrading is safe.** A newer build always reads documents written by an
163
163
  older one. Each schema version's wire format is frozen and never changes;
164
164
  when the model does change, the new build ships a migration that converts
165
165
  old payloads on `fromJson`. A document you commit as your canonical
166
- on-disk format keeps loading across crate upgrades there is no need to
166
+ on-disk format keeps loading across crate upgrades: there is no need to
167
167
  pin old wasm to read old data.
168
168
  - **Downgrading is not.** `fromJson` rejects an *unknown* (i.e. newer)
169
169
  `schema` version rather than guessing at a format it predates. Don't feed
@@ -178,13 +178,13 @@ if (v && v !== Document.currentSchemaVersion()) {
178
178
  }
179
179
  ```
180
180
 
181
- `schemaVersionOf` does not validate the payload it only reads the
181
+ `schemaVersionOf` does not validate the payload: it only reads the
182
182
  `schema` field, returning `undefined` for non-JSON, non-objects, or
183
183
  payloads that don't carry one. Use it to distinguish "wrong version" from
184
184
  "corrupt" when `fromJson` throws.
185
185
 
186
186
  In short: persist the `toJson` string, upgrade freely, never downgrade. The
187
- full design including how migrations are added is in
187
+ full design (including how migrations are added) is in
188
188
  `prose/canon/DOCUMENT_STORAGE.md`.
189
189
 
190
190
  ### `doc.equals(other)`
@@ -201,7 +201,7 @@ Use this to validate indices before calling card mutators (`removeCard`,
201
201
 
202
202
  ### `quill.validate(doc)`
203
203
 
204
- Returns `Diagnostic[]` the document validated against the quill schema,
204
+ Returns `Diagnostic[]`: the document validated against the quill schema,
205
205
  without invoking the backend. An empty array means the document is valid.
206
206
  Each diagnostic carries the canonical `validation::*` `code`, `path`, and
207
207
  `hint`. Includes the non-fatal `validation::must_fill` warning for each
@@ -214,14 +214,14 @@ const errors = diagnostics.filter(d => d.severity === "error");
214
214
  ```
215
215
 
216
216
  To render a form editor, read field definitions from `quill.schema` (walk
217
- `fields` in key order declaration order is display order) and the authored
218
- values from the `Document` payload there is no separate form-view projection.
217
+ `fields` in key order: declaration order is display order) and the authored
218
+ values from the `Document` payload: there is no separate form-view projection.
219
219
 
220
220
  ### `quill.seedDocument()`
221
221
 
222
222
  Returns a starter `Document` seeded from the schema: each field's `example:`
223
223
  is committed and every other field is left absent (the render layer fills
224
- `default:` → type-empty zero). Illustration-first a field with both an
224
+ `default:` → type-empty zero). Illustration-first: a field with both an
225
225
  `example` and a `default` renders its example. Use as the initial state for a
226
226
  "new document" editor.
227
227
 
@@ -243,7 +243,7 @@ doc.insertCard({ kind: "note" }, 0); // insert at index 0
243
243
  ```
244
244
 
245
245
  Reads and writes are two aligned shapes. A read `Card` always has `body:
246
- Content` (canonical content, never a raw string) no narrowing, no guessing
246
+ Content` (canonical content, never a raw string): no narrowing, no guessing
247
247
  whether the body was normalized. The write shape `CardInput` widens `body` to
248
248
  `Content | string` (a markdown string imports to the content) and makes every
249
249
  field but `kind` optional. Every `Card` is a valid `CardInput`, so `insertCard`
@@ -251,8 +251,8 @@ still takes exactly what `cards` / `removeCard` / `seedCard` return.
251
251
  Build a fresh card from a flat field map with
252
252
  `Document.makeCard(kind, fields?, body?)`.
253
253
 
254
- **One address for the whole surface.** Reads and writes navigate by an `Addr` —
255
- `{ card?, field? }`, absent `card` = main, absent `field` = body and a bare
254
+ **One address for the whole surface.** Reads and writes navigate by an `Addr`:
255
+ `{ card?, field? }`, absent `card` = main, absent `field` = body, and a bare
256
256
  string is shorthand for `{ field }`. So `doc.storeField("qty", 3)` targets the
257
257
  main card's `qty`, `doc.storeField({ card: 2, field: "qty" }, 3)` a composable
258
258
  card's. Reads are total over the field axis (`getStored` → `undefined`, `isFill` → `false` for
@@ -263,10 +263,10 @@ markdown is read through `quill.reader(doc).get(field)`). Card-scoped verbs take
263
263
  `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
264
264
 
265
265
  Batch mutation: `doc.storeFields({}, {...})` / `doc.storeFields({ card: index }, {...})`
266
- apply a whole object atomically on any invalid field nothing is applied and
266
+ apply a whole object atomically: on any invalid field nothing is applied and
267
267
  the thrown error carries one diagnostic per offending field (`path` = field
268
268
  name). The address is first (never shape-overloaded, since `card` is a legal
269
- field name), and parses strictly a stray key throws rather than silently
269
+ field name), and parses strictly: a stray key throws rather than silently
270
270
  reading as `{}`. The main card is `{}`, or **`MAIN_CARD_ADDR`** (from
271
271
  `@quillmark/wasm/runtime`), a frozen alias that spells the intent:
272
272
  `doc.storeFields(MAIN_CARD_ADDR, {...})`.
@@ -277,12 +277,12 @@ A `Document` holds only a `$quill` *reference*, not the resolved schema, so type
277
277
  writes go through the schema-bound writer while the quill-free opaque store sits
278
278
  on `Document` itself (**store** = verbatim, **set** = typed):
279
279
 
280
- - **`quill.writer(doc)` the typed door whenever a quill is in hand.** Bind the
280
+ - **`quill.writer(doc)`: the typed door whenever a quill is in hand.** Bind the
281
281
  schema once and issue bare `set` / `setAll` / `setBody` / `reviseField` /
282
282
  `addCard` / `card(i)`. Each resolves the field's schema `type`, coerces the
283
283
  value to its canonical form (`"3"` → `3`, a markdown string → a richtext
284
284
  content), and **fails now** on a mismatch instead of at render. A name the schema
285
- does not declare throws `UnknownField` rather than falling to the opaque store
285
+ does not declare throws `UnknownField` rather than falling to the opaque store:
286
286
  on the typed path an undeclared name is a typo, not a fallback. The batch form
287
287
  (`setAll`) is all-or-nothing: an undeclared name aborts the whole write and its
288
288
  per-field diagnostics name every offending field, so a whole-form submit
@@ -290,21 +290,21 @@ on `Document` itself (**store** = verbatim, **set** = typed):
290
290
  carries the quill-taking `_commitField` / `_commitFields` / `_addCard` /
291
291
  `_reviseField` ABI the writer delegates to, hidden from the `.d.ts`.)
292
292
 
293
- - **`store*` the deliberate quill-free primitive.** `doc.storeField(addr, value)`
293
+ - **`store*`: the deliberate quill-free primitive.** `doc.storeField(addr, value)`
294
294
  / `doc.storeFields(cardAddr, {...})` (and `storeFill`) validate only the field
295
295
  name/depth/kind and store the value verbatim, no quill required. Reach for it
296
296
  on purpose when you *want* the opaque store: quill-agnostic storage/migration
297
297
  infra that has no bundle and must write regardless of a drifted schema;
298
298
  store-now-validate-later editors holding in-progress input that `commit`
299
299
  would reject; or verbatim passthrough of fields the schema doesn't own. It is
300
- the lower layer, not a lighter `commit` a typo'd field name stores silently
300
+ the lower layer, not a lighter `commit`: a typo'd field name stores silently
301
301
  and only surfaces at `quill.validate` / render.
302
302
 
303
303
  Per-keystroke cost is the same either way (both mutate the in-memory `Document`
304
304
  in place; no seam is crossed), so steering to the writer buys the type check for
305
305
  free.
306
306
 
307
- #### `DocumentWriter` / `CardWriter` bind the quill once
307
+ #### `DocumentWriter` / `CardWriter`: bind the quill once
308
308
 
309
309
  `quill.writer(doc)` binds the quill's schema to the document once, so a form
310
310
  editor or MCP writer that holds both issues bare verbs (the writer forwards to
@@ -315,25 +315,25 @@ const ed = quill.writer(doc); // Rust `quill.writer(doc)`
315
315
  ed.set("subject", "Q3 results"); // strict-committed to the schema type
316
316
  ed.setAll({ qty: "3", subject: "Q3" }); // all-or-nothing batch
317
317
  ed.reviseField("subject", "Q3 **results**"); // typed AND anchor-preserving; returns a Delta
318
- ed.set("titel", "x"); // throws UnknownField a typo, not a fallback
318
+ ed.set("titel", "x"); // throws UnknownField: a typo, not a fallback
319
319
  ed.card(2).set("body", "**note**"); // composable card, resolved by its $kind
320
320
  ```
321
321
 
322
322
  `DocumentWriter` / `CardWriter` are pure JS holding references to your existing
323
- `quill` and `doc` no WASM handle of their own, nothing to `free()`. `card(i)`
323
+ `quill` and `doc`: no WASM handle of their own, nothing to `free()`. `card(i)`
324
324
  is lazy: it never throws; an out-of-range index throws `IndexOutOfRange` at the
325
325
  write.
326
326
 
327
327
  ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
328
328
 
329
- Use **`engine.render`** for one-shot exports (PDF/SVG/PNG) compiles, emits
329
+ Use **`engine.render`** for one-shot exports (PDF/SVG/PNG): compiles, emits
330
330
  artifacts, done. Use **`LiveSession`** (returned by `engine.open`) for
331
331
  reactive previews: the session is a persistent compiler. `paint` / `render` /
332
332
  `regions` / `fieldAt` read its current compile without recompiling, and `apply(doc)`
333
333
  recompiles in place on each edit, returning a `ChangeSet` whose `dirtyPages`
334
- tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional
334
+ tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional:
335
335
  on throw, every read keeps serving the last-good compile. Don't open a session
336
- per export, and don't re-open per edit `apply` instead.
336
+ per export, and don't re-open per edit: `apply` instead.
337
337
 
338
338
  ### `engine.render(quill, parsed, opts?)`
339
339
  Render a pre-parsed `Document` against `quill`. Throws an
@@ -344,7 +344,7 @@ declared backend.
344
344
  Open once, render all or selected pages (`opts.pages`).
345
345
 
346
346
  The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
347
- `warnings` (non-fatal diagnostics of the current compile set at `open`,
347
+ `warnings` (non-fatal diagnostics of the current compile: set at `open`,
348
348
  refreshed by each committed `apply`),
349
349
  `apply(doc)` for in-place recompiles, `pageSize(page)`, and
350
350
  `paint(ctx, page, opts?)` for canvas previews. See below.
@@ -361,7 +361,7 @@ render a "no pages to preview" UI without relying on the throw.
361
361
  `OffscreenCanvasRenderingContext2D` (Worker), skipping PNG/SVG byte
362
362
  round-trips.
363
363
 
364
- The painter owns `canvas.width` / `canvas.height` it sizes the backing
364
+ The painter owns `canvas.width` / `canvas.height`: it sizes the backing
365
365
  store itself. Consumers own `canvas.style.*` (or the layout system that
366
366
  sets them) and read `layoutWidth` / `layoutHeight` from the returned
367
367
  `PaintResult`.
@@ -390,16 +390,16 @@ canvas.style.height = `${result.layoutHeight}px`;
390
390
  clamped page renders soft at the same `canvas.style` size.
391
391
  - `paint` writes the whole backing store with `putImageData`, which
392
392
  ignores the 2D context transform, `globalAlpha`, and clip. Give each
393
- visible page its own `` element you cannot composite two pages,
393
+ visible page its own `<canvas>` element: you cannot composite two pages,
394
394
  a sub-rect, or a context transform through `paint`.
395
- - `paint` is always a full repaint setting the backing-store width /
395
+ - `paint` is always a full repaint: setting the backing-store width /
396
396
  height clears it. No `clearRect` required. Each call re-rasterizes from
397
397
  scratch (no per-page raster cache), so keep a page's canvas alive while
398
398
  it stays near the viewport rather than pooling one canvas across pages:
399
399
  an idle canvas retains its pixels for free, whereas reusing a canvas on
400
400
  scroll re-runs a full render.
401
401
  - `pageCount` and `pageSize(page)` are stable for the session's
402
- lifetime (immutable snapshot) cache them.
402
+ lifetime (immutable snapshot): cache them.
403
403
  - Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
404
404
  same call signature works. `layoutWidth` / `layoutHeight` are
405
405
  informational in that mode (no CSS layout box); fold everything into
@@ -415,14 +415,14 @@ canvas.style.height = `${result.layoutHeight}px`;
415
415
 
416
416
  A field's *cell* is inferred from whether its schema declares a `default:`:
417
417
 
418
- - **Unendorsed** (no `default:`) `quill.blueprint` renders the
418
+ - **Unendorsed** (no `default:`): `quill.blueprint` renders the
419
419
  `!must_fill` marker in the value cell (carrying the field's `example` as a
420
420
  suggested value when one exists). An absent Unendorsed field zero-fills
421
421
  silently. A `!must_fill` marker left in the document is non-fatal: it emits
422
422
  the `validation::must_fill` warning and still renders. Partial documents
423
423
  are accepted; `engine.render(quill, doc)` only throws for malformed
424
424
  input.
425
- - **Endorsed** (with `default:`) `quill.blueprint` renders the
425
+ - **Endorsed** (with `default:`): `quill.blueprint` renders the
426
426
  default value with a type-only `# <type>` annotation (shippable as-is),
427
427
  and the default is used when the document omits the field.
428
428
 
@@ -431,7 +431,7 @@ document emits the non-fatal `validation::must_fill` warning.
431
431
 
432
432
  ### Errors
433
433
 
434
- Every method that can fail throws a **`QuillmarkError`** a JS `Error` with
434
+ Every method that can fail throws a **`QuillmarkError`**: a JS `Error` with
435
435
  `.diagnostics` attached. The type and a guard are exported from the root:
436
436
 
437
437
  ```ts
@@ -443,17 +443,17 @@ try {
443
443
  if (isQuillmarkError(e)) {
444
444
  for (const d of e.diagnostics) console.error(d.severity, d.message);
445
445
  } else {
446
- throw e; // not a quillmark failure programming error, re-throw
446
+ throw e; // not a quillmark failure: programming error, re-throw
447
447
  }
448
448
  }
449
449
  ```
450
450
 
451
- `QuillmarkError` is a **structural interface, not a class** the WASM layer
451
+ `QuillmarkError` is a **structural interface, not a class**: the WASM layer
452
452
  throws a real `Error` and attaches the property, so there is no constructor to
453
453
  `instanceof` against; narrow with `isQuillmarkError` (which also works on
454
454
  errors from any build or WASM instance in the page).
455
455
 
456
- `diagnostics` is always non-empty length 1 for most failures, length N for
456
+ `diagnostics` is always non-empty: length 1 for most failures, length N for
457
457
  backend compilation errors. `message` is derived from `diagnostics`
458
458
  (`diagnostics[0].message` for single-diagnostic errors; an aggregate
459
459
  `"<N> error(s): <first.message>"` summary for compilation failures).
@@ -461,15 +461,20 @@ backend compilation errors. `message` is derived from `diagnostics`
461
461
  Read `err.diagnostics[0]` for the primary diagnostic; iterate the array for
462
462
  compilation failures. The same shape applies to every throw site:
463
463
 
464
- - `Document.fromMarkdown` parse errors (missing root `$quill` metadata, YAML
465
- errors, `parse::input_too_large` for inputs > 10 MB).
466
- - `Document` mutators (`storeField`, the writer's `set`, etc.) mutator
464
+ - `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
465
+ errors, `parse::input_too_large` for inputs > 10 MiB).
466
+ - `Document` mutators (`storeField`, the writer's `set`, etc.): mutator
467
467
  failures carry a namespaced `edit::*` `code` on `diagnostics[0]`
468
468
  (`edit::invalid_field_name`, `edit::unknown_field`, `edit::index_out_of_range`,
469
469
  `edit::field_conform`, …). Route on `diagnostics[0].code`, never on message
470
470
  text.
471
- - `engine.render` / `session.render` backend compilation failures and
471
+ - `engine.render` / `session.render`: backend compilation failures and
472
472
  validation errors.
473
+ - Any method taking a `Quill` or `Document`: a handle from a *second* copy of
474
+ `@quillmark/wasm` is refused with `runtime::foreign_handle`, hinting `npm ls
475
+ @quillmark/wasm`. Two copies are two WASM memories and two `Quill`/`Document`
476
+ classes; dedupe to one. A value that is not a handle at all keeps its own
477
+ `runtime::not_a_document` / `runtime::not_a_quill`.
473
478
 
474
479
  ### Lifecycle
475
480
 
@@ -480,17 +485,15 @@ teardown hook for callers that want deterministic release.
480
485
 
481
486
  `engine.render` and `engine.open` read the `quill` and `doc` handles
482
487
  synchronously, before their first await, so freeing a handle as soon as the
483
- call returns `try { return engine.render(quill, doc); } finally
484
- { doc.free(); }` is safe even on the first render, while the backend
488
+ call returns: `try { return engine.render(quill, doc); } finally
489
+ { doc.free(); }`: is safe even on the first render, while the backend
485
490
  binary is still loading.
486
491
 
487
492
  The package floor is Node 22+ (`engines: { node: ">=22" }`) and current
488
493
  evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
489
- sugar shown below ([explicit resource management][erm]) needs Node 24, but is
490
- optional the `try` / `finally` fallback runs on the Node 22 floor.
491
-
492
- For environments where `using` (the [explicit resource management][erm]
493
- proposal) hasn't landed, use an explicit `try` / `finally`:
494
+ sugar ([explicit resource management][erm]) needs Node 24 and is optional.
495
+ Where it hasn't landed, an explicit `try` / `finally` runs on the Node 22
496
+ floor:
494
497
 
495
498
  ```ts
496
499
  const session = await engine.open(quill, doc);