Skip to content

Grida Canvas - Image Persistency#495

Merged
softmarshmallow merged 1 commit intomainfrom
canary
Jan 16, 2026
Merged

Grida Canvas - Image Persistency#495
softmarshmallow merged 1 commit intomainfrom
canary

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Jan 15, 2026

  • New Features

    • Added color picker functionality to select and apply colors to selections.
    • Enhanced image asset management with local persistence support.
  • Bug Fixes

    • Improved image loading resilience with graceful error handling.
  • Refactor

    • Updated image reference system and persistence format.

…ading, persisting, and rewriting image references in documents
@vercel
Copy link
Copy Markdown

vercel Bot commented Jan 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
docs Ready Ready Preview, Comment Jan 15, 2026 4:37pm
grida Ready Ready Preview, Comment Jan 15, 2026 4:37pm
5 Skipped Deployments
Project Deployment Review Updated (UTC)
code Ignored Ignored Jan 15, 2026 4:37pm
legacy Ignored Ignored Jan 15, 2026 4:37pm
backgrounds Skipped Skipped Jan 15, 2026 4:37pm
blog Skipped Skipped Jan 15, 2026 4:37pm
viewer Skipped Skipped Jan 15, 2026 4:37pm

Review with Vercel Agent

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 15, 2026

Walkthrough

This PR introduces comprehensive image asset handling across the editor stack: OPFS-based persistence for images in the playground, new editor APIs for archiving documents with embedded image data, hash-based image reference serialization in the format layer, and expanded OPFS interface methods for image operations. Additionally, color picker UI utilities are added to the editor surface, and serialization tests are updated to reflect container-based image paint representation.

Changes

Cohort / File(s) Summary
OPFS Image Persistence
editor/grida-canvas-hosted/playground/playground.tsx
Added image asset loading from OPFS (images/) on document load, building an images map and applying to WASM surface. On save, images are persisted to OPFS via opfs.writeImage, with document state now using archivedir() output instead of snapshot. Effect dependency arrays updated to include backend state.
Editor Archive & Image APIs
editor/grida-canvas/editor.ts
Added archivedir() returning document with embedded image metadata, archive() delegating to archivedir for ZIP creation, and loadImagesToWasmSurface() for runtime image registration. Added private _pending_image_assets field and image ref parsing/rewriting helpers. Refactored paint resolution via editor.resolvePaints() for both WASM and DOM paths. Added promptColorPicker() and surfacePickColor() to EditorSurface for color picking UX.
Image Paint Query
editor/grida-canvas/query/index.ts
Added image_paints() and image_srcs() methods to DocumentStateQuery to extract and deduplicate image paint sources across all nodes using paint resolution.
Hash-based Image References
packages/grida-canvas-io/format.ts
Changed ImagePaint encoding/decoding from ResourceRefRID to ResourceRefHASH. Added hex16-based validation and normalization of paint.src inputs (supporting bare hex16, res://images/, mem://). Updated ImagePaint type field to HASH in both paths.
OPFS & Archive Interface
packages/grida-canvas-io/index.ts
Added optional assets.images field to LoadedDocument exposing raw image bytes. Extended archive Manifest with optional images index. Expanded OPFS Handle with writeImage(), listImages(), readImage() methods. Modified ZIP loading to populate hash-addressed assets map and update imagesRepo with hash-keyed references.
Image Paint Roundtrip Tests
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
Replaced ImageNode decode test with active ContainerNode containing fill_paints with image paint. Expanded BoxFit test matrix to include "none" option. Updated image src references to deterministic placeholders and adjusted assertions to validate container-based image paint representation.

Sequence Diagram(s)

sequenceDiagram
    participant Playground as Playground
    participant OPFS as OPFS Storage
    participant Editor as Editor
    participant WASM as WASM Surface
    participant Archive as Archive/ZIP

    Note over Playground,Archive: Image Load Flow
    Playground->>OPFS: readImage(hash)
    OPFS-->>Playground: Uint8Array
    Playground->>Editor: loadImagesToWasmSurface(images)
    Editor->>WASM: Register image assets
    WASM-->>Editor: Pending assets buffered if not mounted
    Editor->>WASM: Apply pending assets on mount
    
    Note over Playground,Archive: Image Save Flow
    Playground->>Editor: archivedir()
    Editor-->>Playground: {document with images, image bytes}
    Playground->>Archive: Create ZIP with document + images/
    Playground->>OPFS: writeImage(hash, bytes)
    OPFS-->>Playground: Write complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

Suggested labels

daily

Poem

🐰 Hop, hop, the images flow,
From OPFS to WASM they go,
Hashes rewrite, paints resolve with care,
Archive and load with assets to spare!
Colors picked from pixels bright,
A canvas journey, pixel-perfect right!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Grida Canvas - Image Persistency' directly and clearly describes the main objective of the pull request, which focuses on implementing image persistence across multiple files and components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch canary

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8a42eac5a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2912 to +2916
const usedSrcs = new dq.DocumentStateQuery(snapshot).image_srcs();
if (this.backend !== "canvas" || !this._m_wasm_canvas_scene) {
throw new Error(
"`archivedir()` requires the canvas/WASM backend to be mounted."
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow archive/export on DOM backend

archivedir() now throws unless the canvas/WASM backend is mounted, but the playground defaults to backend="dom" and both Cmd/Ctrl+S and export call archivedir() (see playground save/export flow). This means saving or exporting from the default DOM backend will always hit the exception and show "Failed to save" even though the comment says DOM should persist without embedding. Consider allowing DOM to bypass WASM asset collection (returning the snapshot as‑is) so non‑canvas backends can still save/export.

Useful? React with 👍 / 👎.

Comment on lines +1921 to +1924
if (!/^[0-9a-f]{16}$/.test(src)) {
throw new Error(
`ImagePaint.src must be hex16 (or res://images/<hex16>, mem://<hex16>) for persistence. Got: "${paint.src}"`
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t reject URL image paints during encode

The encoder now throws unless ImagePaint.src is a hex hash or res:///mem:// form. In the DOM backend, image paints are created from external URLs (e.g., drag‑drop uses imageRef.url), and existing documents may persist URL sources. Those documents will now fail io.GRID.encode/archive save with a thrown error, breaking persistence for DOM workflows. Consider accepting URL/RID values for non‑WASM backends or preserving them when encoding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/grida-canvas-io/format.ts (1)

2269-2326: Decode ImagePaint losslessly (quarter_turns + transform/tile scale), otherwise persistence is incomplete.

encode.paintTypes.image() persists quarterTurns and fit union payloads, but decode.paintTypes.image() drops them. Any docs using quarter rotations / transform-fit / tile-fit won’t roundtrip correctly after reload.

Proposed fix (decode quarter_turns, transform, tile scale)
 export function image(paintValue: unknown): cg.ImagePaint {
   // NOTE: hash-only decode for image resource reference.
   const imagePaint = paintValue as fbs.ImagePaint;

   // Decode src from ResourceRef
   let src = "";
   const imageType = imagePaint.imageType();
   if (imageType === fbs.ResourceRef.ResourceRefHASH) {
     const resourceRef = imagePaint.image(
       new fbs.ResourceRefHASH()
     ) as fbs.ResourceRefHASH | null;
     if (resourceRef) {
       // NOTE: WASM renderer currently uses `res://images/<hex16>` as the cross-boundary image id.
       // Persisted form remains plain hex16; decode returns the runtime identifier.
       //
       // TODO(resources): once `res://<dir>/<id>` is fully generalized, avoid hardcoding `images/`.
       const hash = resourceRef.hash() ?? "";
       src = hash ? `res://images/${hash}` : "";
     }
   }

   // Decode fit from ImagePaintFit union
   let fit: cg.BoxFit | "transform" | "tile" = "cover";
+  let transform: cg.AffineTransform | undefined = undefined;
+  let scale: number | undefined = undefined;
   const fitType = imagePaint.fitType();
   if (fitType === fbs.ImagePaintFit.ImagePaintFitFit) {
     const fitFit = imagePaint.fit(
       new fbs.ImagePaintFitFit()
     ) as fbs.ImagePaintFitFit | null;
     if (fitFit) {
       const boxFit = fitFit.boxFit();
       fit = enums.BOX_FIT_DECODE.get(boxFit) ?? "cover";
     }
   } else if (fitType === fbs.ImagePaintFit.ImagePaintFitTransform) {
     fit = "transform";
-    // TODO: decode transform if needed
+    const fitTransform = imagePaint.fit(
+      new fbs.ImagePaintFitTransform()
+    ) as fbs.ImagePaintFitTransform | null;
+    transform = decodeGradientTransform(fitTransform?.transform() ?? null);
   } else if (fitType === fbs.ImagePaintFit.ImagePaintFitTile) {
     fit = "tile";
-    // TODO: decode tile scale if needed
+    const fitTile = imagePaint.fit(
+      new fbs.ImagePaintFitTile()
+    ) as fbs.ImagePaintFitTile | null;
+    scale = fitTile?.tile()?.scale() ?? 1.0;
   }

+  const qt = imagePaint.quarterTurns() ?? 0;
   return {
     type: "image",
     src,
     fit,
+    ...(fit === "transform" ? { transform } : {}),
+    ...(fit === "tile" ? { scale } : {}),
+    ...(qt ? { quarter_turns: qt % 4 } : {}),
     blend_mode: styling.decode.blendMode(imagePaint.blendMode()),
     opacity: imagePaint.opacity(),
     active: imagePaint.active(),
     filters: {
       exposure: imagePaint.filters()?.exposure() ?? 0.0,
       contrast: imagePaint.filters()?.contrast() ?? 0.0,
       saturation: imagePaint.filters()?.saturation() ?? 0.0,
       temperature: imagePaint.filters()?.temperature() ?? 0.0,
       tint: imagePaint.filters()?.tint() ?? 0.0,
       highlights: imagePaint.filters()?.highlights() ?? 0.0,
       shadows: imagePaint.filters()?.shadows() ?? 0.0,
     },
   } satisfies cg.ImagePaint;
 }
🤖 Fix all issues with AI agents
In `@editor/grida-canvas/editor.ts`:
- Around line 3380-3391: The reducer unconditionally mutates
state.document.images which can throw if images is undefined and can mark the
document dirty even when metadata already exists; inside the this.doc.reduce
callback (used by loadImagesToWasmSurface) ensure state.document.images is
initialized if missing (create an empty object) and only assign
state.document.images[hash] when that key is absent (i.e., skip writing if
metadata already exists) so you avoid errors and unnecessary mutations that flip
the dirty flag.

In `@editor/grida-canvas/query/index.ts`:
- Around line 616-668: image_srcs() currently collects srcs only from image
paints via image_paints(), missing node-level images/videos; update image_srcs()
to also iterate this.nodeids and collect src from nodes that are image or video
types (e.g., check node.type === "image" || node.type === "video" or use
cg.isImageNode / cg.isVideoNode if available) and include their node.src values
(filtering falsy) into the Set so archivedir()/persistence covers both
paint-based and node-based media sources.
- Around line 1-4: query/index.ts creates a circular runtime import with
editor.i.ts because it imports editor at module top-level while editor.i.ts
imports dq from this module; fix by removing the top-level import of editor and
performing a local (lazy) require/import for the editor symbol only inside the
functions that call editor.resolvePaints (the call sites around the current
lines labeled 551, 638, 671), e.g., inside the functions that need resolvePaints
do const { editor } = await import("..") or require("..") and then call
editor.resolvePaints; alternatively, move any query code that depends on
editor.resolvePaints into editor.i.ts or a new module that does not import dq to
eliminate the bidirectional dependency.
🧹 Nitpick comments (10)
editor/grida-canvas/editor.ts (2)

2835-2869: __rewriteImagePaintSrcDeep should skip ArrayBuffer/TypedArrays to avoid huge traversals

Right now __isRecord() will treat Uint8Array etc. as a record and recurse over indices if any binary data ever ends up on the document object (e.g. future thumbnail bytes).

Proposed fix
@@
   private static __rewriteImagePaintSrcDeep(
     value: unknown,
     mapSrc: (src: string) => string | undefined
   ): number {
     if (Array.isArray(value)) {
@@
     }
+    // Avoid walking binary blobs (ArrayBuffer, Uint8Array, etc.)
+    if (value instanceof ArrayBuffer) return 0;
+    if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value)) return 0;
     if (!Editor.__isRecord(value)) return 0;

3013-3024: Pending image assets: avoid overwriting on multiple calls before mount

loadImagesToWasmSurface() currently overwrites _pending_image_assets (Line 3319). If multiple loads happen before mount (e.g. document load + additional asset fetch), earlier assets are dropped. Merging is safer, and it makes the Line 3016 flush more robust.

Proposed fix
@@
   public loadImagesToWasmSurface(images: Record<string, Uint8Array>) {
     if (!images || Object.keys(images).length === 0) return;
 
     // If we're on canvas backend but not mounted yet, defer.
     if (this.backend === "canvas" && !this._m_wasm_canvas_scene) {
-      this._pending_image_assets = images;
+      this._pending_image_assets = {
+        ...(this._pending_image_assets ?? {}),
+        ...images,
+      };
       return;
     }

Also applies to: 3265-3336

editor/grida-canvas-hosted/playground/playground.tsx (2)

466-525: OPFS load: consider loading only images referenced by the loaded document

listImages() + reading every file works, but OPFS will likely accumulate stale images over time. Since you already have loadedDocument before resetting, you can compute the required hashes (via the new query helpers) and only read those.


647-671: Save path: archivedir() dependency on mounted WASM should be gated (or archivedir() must support DOM)

Line 654 calls instance.archivedir() unconditionally (inside if (opfs)), which will now fail for DOM backend unless archivedir() has a DOM fallback. Also consider pruning images that are no longer referenced to avoid unbounded OPFS growth.

Possible guard (if you want save-to-OPFS to work on DOM backend without embedding)
@@
     async () => {
       if (opfs) {
         try {
-          const snapshot = instance.getSnapshot();
-          const dir = instance.archivedir();
+          const snapshot = instance.getSnapshot();
+          const dir =
+            backend === "canvas"
+              ? instance.archivedir()
+              : { document: snapshot.document, images: {} as Record<string, Uint8Array> };
@@
           // Write images into OPFS (images/<hash>.<ext>)
           for (const [filename, bytes] of Object.entries(dir.images)) {
             await opfs.writeImage(filename, bytes);
           }
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts (1)

848-912: Test updated to reflect container-based image paint representation — LGTM.

The changes correctly align with the new hash-based image persistence model and expand the test matrix to include the "none" fit option.

Consider adding an assertion to verify the src transformation after roundtrip. According to the format changes, encoding normalizes src to hex16 and decoding returns res://images/<hash>, so the roundtripped paint.src should be "res://images/0123456789abcdef".

💡 Optional: Add src verification
       expect(paint.fit).toBe(expected);
+      expect(paint.src).toBe("res://images/0123456789abcdef");
     });
packages/grida-canvas-io/index.ts (4)

775-799: Normalize + validate ZIP image keys to canonical hex16 (lowercase), and guard against collisions.

Right now hashHex parsing is “best effort” (split(".")[0]) and doesn’t enforce the same canonical format as format.ts does; tightening this makes cross-layer lookups more reliable.

Proposed hardening for hash parsing
 for (const [key, imageData] of Object.entries(_x_images)) {
-  const base = key.split("/").pop() ?? key;
-  const hashHex = base.includes(".") ? base.split(".")[0]! : base;
+  const base = (key.split("/").pop() ?? key).trim();
+  const dot = base.lastIndexOf(".");
+  const hashHex = (dot > 0 ? base.slice(0, dot) : base).toLowerCase();
+  if (!/^[0-9a-f]{16}$/.test(hashHex)) {
+    throw new Error(`Invalid image filename (expected <hex16>[.<ext>]): ${key}`);
+  }
+  if (hashHex in assets) {
+    throw new Error(`Duplicate image hash in archive: ${hashHex}`);
+  }

   const dimensions = imageSize(new Uint8Array(imageData));
   if (!dimensions || !dimensions.width || !dimensions.height) {
     throw new Error(`Failed to get dimensions for image: ${key}`);
   }
   const { width, height, type } = dimensions;
   const mimeType = IMAGE_TYPE_TO_MIME_TYPE[type || "png"] || "image/png";

   assets[hashHex] = imageData;
   imagesRepo[hashHex] = {
     url: hashHex,
     width,
     height,
     bytes: imageData.byteLength,
     type: mimeType,
   };
 }

894-918: Manifest images index looks fine, but clarify “best-effort” vs “guaranteed”.

The type includes mime/width/height, but pack currently doesn’t populate them; callers may assume they exist. Either fill these (or explicitly document they’re optional/best-effort, as you started).


971-1004: Harden manifest.images keying and ZIP entry naming (avoid split(".")[0] + pathy keys).

name.split(".")[0] will mis-handle filenames with multiple dots, and files[\images/${key}`]trustskeyverbatim (surprising if it contains//..). Even if today keys are always .`, it’s worth enforcing that contract at the boundary.

Proposed normalization + validation
 if (images && Object.keys(images).length > 0) {
   const index: NonNullable<Manifest["images"]> = {};
   for (const [key, imageData] of Object.entries(images)) {
-    const name = key.split("/").pop() ?? key;
-    const ext = name.includes(".") ? name.split(".").pop() : undefined;
-    index[name.split(".")[0] ?? name] = {
-      ext,
-      bytes: imageData.byteLength,
-    };
+    if (key.includes("/") || key.includes("\\") || key.includes("..")) {
+      throw new Error(`Invalid image key: ${key}`);
+    }
+    const name = key.trim();
+    const dot = name.lastIndexOf(".");
+    const id = (dot > 0 ? name.slice(0, dot) : name).toLowerCase();
+    const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : undefined;
+    if (!/^[0-9a-f]{16}$/.test(id)) {
+      throw new Error(`Invalid image id (expected hex16): ${id}`);
+    }
+    index[id] = { ext, bytes: imageData.byteLength };
   }
   manifest.images = index;
 }

 // Add images
 if (images && Object.keys(images).length > 0) {
   // ...
   for (const [key, imageData] of Object.entries(images)) {
-    files[`images/${key}`] = imageData;
+    if (key.includes("/") || key.includes("\\") || key.includes("..")) {
+      throw new Error(`Invalid image key: ${key}`);
+    }
+    files[`images/${key.trim()}`] = imageData;
   }
 }

1187-1191: Add filename validation and consistent error handling to OPFS image helpers.

The writeImage() and readImage() methods accept arbitrary filenames and pass them directly to getFileHandle() without validation or error wrapping. Per the File System Access API spec, getFileHandle(name) only accepts single path segments and forbids "/" and NUL characters—invalid names trigger a TypeError that goes unhandled. Additionally, other methods in this class wrap OPFS errors consistently (distinguishing NotFoundError from other IO failures), but these image helpers do not, making it impossible for callers to handle "not found" vs "IO error" cleanly.

Add a simple filename validator (single path segment, non-empty) and wrap errors to match the existing pattern in the class (see the FileKey getter handlers for the error structure).

Proposed validator and error wrapping
 export class Handle {
   private _dirHandle: FileSystemDirectoryHandle | null = null;
   private _dirHandlePromise: Promise<FileSystemDirectoryHandle> | null =
     null;
   private _fileHandles = new Map<FileKey, FileHandle>();

+  private normalizeImageFilename(filename: string): string {
+    const name = filename.trim();
+    if (
+      !name ||
+      name === "." ||
+      name === ".." ||
+      name.includes("/") ||
+      name.includes("\\") ||
+      name.includes("\0")
+    ) {
+      throw new Error(`Invalid image filename: ${filename}`);
+    }
+    return name;
+  }

   // ...

   async writeImage(filename: string, bytes: Uint8Array): Promise<void> {
+    const name = this.normalizeImageFilename(filename);
     // ...
     const dir = await this.getDirectoryHandle();
     const imagesDir = await dir.getDirectoryHandle("images", {
       create: true,
     });
-    const fileHandle = await imagesDir.getFileHandle(filename, {
-      create: true,
-    });
-    const writable = await fileHandle.createWritable();
-    await writable.write(bytes as unknown as FileSystemWriteChunkType);
-    await writable.close();
+    try {
+      const fileHandle = await imagesDir.getFileHandle(name, { create: true });
+      const writable = await fileHandle.createWritable();
+      await writable.write(bytes as unknown as FileSystemWriteChunkType);
+      await writable.close();
+    } catch (error) {
+      throw new Error(
+        `Failed to write OPFS images/${name}: ${error instanceof Error ? error.message : String(error)}`
+      );
+    }
   }

   async readImage(filename: string): Promise<Uint8Array> {
+    const name = this.normalizeImageFilename(filename);
     const dir = await this.getDirectoryHandle();
-    const imagesDir = await dir.getDirectoryHandle("images");
-    const fileHandle = await imagesDir.getFileHandle(filename);
-    const file = await fileHandle.getFile();
-    return new Uint8Array(await file.arrayBuffer());
+    try {
+      const imagesDir = await dir.getDirectoryHandle("images");
+      const fileHandle = await imagesDir.getFileHandle(name);
+      const file = await fileHandle.getFile();
+      return new Uint8Array(await file.arrayBuffer());
+    } catch (error) {
+      if (error instanceof DOMException && error.name === "NotFoundError") {
+        throw new Error(`images/${name} not found in OPFS`);
+      }
+      throw new Error(
+        `Failed to read OPFS images/${name}: ${error instanceof Error ? error.message : String(error)}`
+      );
+    }
   }
 }

Also applies to: 1384-1436

packages/grida-canvas-io/format.ts (1)

1899-1932: Extract a shared ImagePaint.src normalization helper to prevent duplication and divergence.

The hash-only validation exists in two places—editor.ts (__try_parse_hex16_from_image_src) and format.ts (encode logic, lines 1914–1925)—with identical implementations. Both strip res://images/ and mem:// prefixes and validate hex16. Since both files carry identical TODO comments about restricting to res:// only, extracting a shared helper is essential to prevent the rules from drifting when one is updated.

Note: The archive code correctly stores plain hex16 keys without extensions (files[images/${key}], not images/${key}.ext), so the current extension concern is a future TODO, not a present drift risk. However, centralizing the normalization will protect against accidental divergence as the resource system evolves.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b5a2760 and a8a42ea.

📒 Files selected for processing (6)
  • editor/grida-canvas-hosted/playground/playground.tsx
  • editor/grida-canvas/editor.ts
  • editor/grida-canvas/query/index.ts
  • packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  • packages/grida-canvas-io/format.ts
  • packages/grida-canvas-io/index.ts
🧰 Additional context used
🧠 Learnings (15)
📓 Common learnings
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas/AGENTS.md:0-0
Timestamp: 2025-12-20T08:11:16.220Z
Learning: Applies to crates/grida-canvas/**/*.rs : Use `skia-safe` crate for painting operations in the rendering engine
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas/AGENTS.md:0-0
Timestamp: 2025-12-20T08:11:16.220Z
Learning: Applies to crates/grida-canvas/**/*.rs : Maintain bidirectional mapping between NodeId and UserNodeId at the application layer for API boundary management
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : Use Canvas 2D API with path commands for rendering geometric shapes (circle, square, triangle, etc.)

Applied to files:

  • packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  • editor/grida-canvas/query/index.ts
  • packages/grida-canvas-io/format.ts
  • editor/grida-canvas/editor.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : For SVG export, convert circles to <circle> elements, rectangles to <rect> elements, and polygons to <polygon> elements with calculated points

Applied to files:

  • packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  • editor/grida-canvas/query/index.ts
  • editor/grida-canvas/editor.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : Cache ImageData and dimensions in refs (imageDataRef, sizeRef) for efficient exports

Applied to files:

  • packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  • editor/grida-canvas-hosted/playground/playground.tsx
  • editor/grida-canvas/query/index.ts
  • packages/grida-canvas-io/index.ts
  • packages/grida-canvas-io/format.ts
  • editor/grida-canvas/editor.ts
📚 Learning: 2025-12-01T00:22:19.083Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-wasm/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:19.083Z
Learning: Applies to crates/grida-canvas-wasm/**/main.rs : Update `grida-canvas-wasm.d.ts` TypeScript definitions file when new APIs are introduced via `main.rs`

Applied to files:

  • packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  • editor/grida-canvas-hosted/playground/playground.tsx
  • editor/grida-canvas/query/index.ts
  • packages/grida-canvas-io/index.ts
  • packages/grida-canvas-io/format.ts
  • editor/grida-canvas/editor.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : Custom images used as halftone shapes should be loaded as HTMLImageElement for efficient canvas rendering and preserve original image colors in as-is mode

Applied to files:

  • packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  • editor/grida-canvas-hosted/playground/playground.tsx
  • editor/grida-canvas/query/index.ts
  • packages/grida-canvas-io/format.ts
  • editor/grida-canvas/editor.ts
📚 Learning: 2025-12-01T00:22:19.083Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-wasm/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:19.083Z
Learning: Applies to crates/grida-canvas-wasm/**/*.{js,ts,jsx,tsx} : Use Web Workers for heavy graphics operations to improve performance and responsiveness

Applied to files:

  • editor/grida-canvas-hosted/playground/playground.tsx
📚 Learning: 2025-12-01T00:22:19.083Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-wasm/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:19.083Z
Learning: Applies to crates/grida-canvas-wasm/**/+(grida-canvas-wasm.js|grida-canvas-wasm.wasm) : Include WASM artifacts (`grida-canvas-wasm.js` and `grida-canvas-wasm.wasm`) in git for faster CI builds

Applied to files:

  • editor/grida-canvas-hosted/playground/playground.tsx
  • packages/grida-canvas-io/index.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : When adding new shape types, update the Shape type union, add cases in drawShape() function, add cases in shapeToSVG() function, and add SelectItem in UI

Applied to files:

  • editor/grida-canvas/query/index.ts
  • editor/grida-canvas/editor.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : Use React hooks for state management (imageSrc, shape, grid, maxRadius, gamma, jitter, opacity, color, customShapeImage, imageDataRef, sizeRef)

Applied to files:

  • editor/grida-canvas/query/index.ts
📚 Learning: 2026-01-10T04:48:31.415Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:48:31.415Z
Learning: Use /editor/grida-* directories to isolate domain-specific modules that can be promoted to <root>/packages when reasonably well-defined

Applied to files:

  • editor/grida-canvas/query/index.ts
📚 Learning: 2025-12-20T08:11:16.220Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas/AGENTS.md:0-0
Timestamp: 2025-12-20T08:11:16.220Z
Learning: Applies to crates/grida-canvas/**/*.rs : Handle NodeId to UserNodeId conversion via `IdConverter` during .grida file loading

Applied to files:

  • editor/grida-canvas/query/index.ts
📚 Learning: 2025-12-20T08:11:16.220Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas/AGENTS.md:0-0
Timestamp: 2025-12-20T08:11:16.220Z
Learning: Applies to crates/grida-canvas/**/*.grida : Validate .grida files using the `tool_io_grida` CLI tool to check file structure, parse all nodes, and detect parsing errors

Applied to files:

  • editor/grida-canvas/query/index.ts
📚 Learning: 2026-01-10T04:48:31.415Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-10T04:48:31.415Z
Learning: Applies to editor/app/(library)/** : Use /editor/app/(library) for Grida Library (open assets) specific pages

Applied to files:

  • editor/grida-canvas/query/index.ts
📚 Learning: 2025-12-01T00:22:41.045Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:41.045Z
Learning: Applies to editor/**/next-env.d.ts : Include `next-env.d.ts` file in git for the project to avoid requiring dev/build runs for typechecking in CI environments

Applied to files:

  • editor/grida-canvas/query/index.ts
🧬 Code graph analysis (5)
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts (2)
packages/grida-canvas-io/format.ts (3)
  • node (1058-1676)
  • paint (2040-2066)
  • paint (2345-2360)
packages/grida-canvas-io-svg/lib.ts (1)
  • paint (35-123)
editor/grida-canvas-hosted/playground/playground.tsx (1)
packages/grida-canvas-io-figma/lib.ts (1)
  • document (673-974)
editor/grida-canvas/query/index.ts (1)
packages/grida-canvas-cg/lib.ts (1)
  • ImagePaint (835-876)
packages/grida-canvas-io/index.ts (1)
packages/grida-canvas-schema/grida.ts (1)
  • ImageRef (709-715)
packages/grida-canvas-io/format.ts (3)
packages/grida-canvas-io-svg/lib.ts (1)
  • paint (35-123)
crates/csscascade/src/tree/mod.rs (1)
  • builder (185-187)
packages/grida-canvas-cg/lib.ts (1)
  • ImagePaint (835-876)
🔇 Additional comments (6)
editor/grida-canvas/editor.ts (2)

713-713: No review comment needed (small refactor)


1821-1875: Nice: fill/stroke mutations now read current paints via editor.resolvePaints()

This should be more robust across legacy/new paint representations than reading raw node properties.

packages/grida-canvas-io/__tests__/format-roundtrip.test.ts (2)

2056-2107: ImagePaint roundtrip test uses correct hex16 format — LGTM.

The test data now uses a deterministic hex16 string which aligns with the hash-based image reference format.


2379-2428: ImagePaint stroke_paints test updated consistently — LGTM.

Uses a distinct hex16 value ("fedcba9876543210") for stroke_paints, maintaining good test isolation from the fill_paints test.

packages/grida-canvas-io/index.ts (2)

599-610: LoadedDocument.assets addition is a clean way to expose raw image bytes.

Good separation: document.images (metadata) vs assets.images (bytes keyed by hash).


935-964: Packing logic is consistent: strip images/bitmaps from FlatBuffers and carry assets in the container.

This keeps the FlatBuffers payload minimal and makes ZIP the canonical asset carrier.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +2880 to 2958
/**
* Prepares a persisted “archive directory” view of the current document state.
*
* This method is shared by both:
* - ZIP archive export (`.grida` via `io.archive.pack`)
* - OPFS persistence (playground `Cmd/Ctrl+S`)
*
* What it does:
* - Collects **used** image paint `src` references in the document.
* - For the WASM backend, resolves those runtime refs (`mem://...`) to bytes and a
* WASM-provided content hash (hex16), producing `images/<hash>.<ext>` entries.
* - Rewrites image paint `src` in the persisted document from runtime URL → hash id.
*
* Notes / policy:
* - We intentionally **do not** compute SeaHash in JS; persisted image IDs come from WASM.
* - For DOM backend (hosted HTML flow), we keep external `src` values as-is and do not embed.
*
* TODO: support `thumbnail.png` generation/persistence alongside the document.
*/
public archivedir(): {
document: grida.program.document.Document;
/**
* Image files to persist under `images/`.
*
* Keys are filenames (e.g. "<hash>.png"), values are bytes.
*/
images: Record<string, Uint8Array>;
} {
const snapshot = this.getSnapshot()
.document as grida.program.document.Document;
const images: Record<string, Uint8Array> = {};

const usedSrcs = new dq.DocumentStateQuery(snapshot).image_srcs();
if (this.backend !== "canvas" || !this._m_wasm_canvas_scene) {
throw new Error(
"`archivedir()` requires the canvas/WASM backend to be mounted."
);
}

// Collect bytes keyed by hash, by reading them back from WASM.
for (const src of usedSrcs) {
const hashHex = Editor.__try_parse_hex16_from_image_src(src);
if (!hashHex) {
throw new Error(
`Cannot persist image paint src (expected res://images/<hex16>, mem://<hex16>, or <hex16>): "${src}"`
);
}
);

// Rust/WASM normalizes non-RID ids into `res://images/<hex16>`.
const bytes = this.__get_image_bytes_for_wasm(hashHex);
if (!bytes) {
throw new Error(
`Cannot persist image bytes (WASM missing resource for "${hashHex}")`
);
}

const mime = snapshot.images?.[hashHex]?.type;
const ext = (mime && mime.split("/")[1]) || "bin";
images[`${hashHex}.${ext}`] = bytes;
}

// Rewrite persisted image paint refs (runtime URL -> hash id) before encoding.
const persisted = structuredClone(snapshot);
Editor.__rewriteImagePaintSrcDeep(persisted, (src) => {
const hex = Editor.__try_parse_hex16_from_image_src(src);
return hex ?? undefined;
});

return { document: persisted, images };
}

public archive(): Blob {
const { document, images } = this.archivedir();
const blob = new Blob([io.archive.pack(document, images) as BlobPart], {
type: "application/zip",
});

return blob;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the __get_image_bytes_for_wasm implementation
echo "=== Finding __get_image_bytes_for_wasm implementation ==="
rg -n "__get_image_bytes_for_wasm" -A 5 --max-count=5

echo "=== Finding archivedir() call sites ==="
rg -n "archivedir\(\)" --max-count=10

echo "=== Locating WASM TypeScript definitions ==="
fd -a 'grida-canvas-wasm.d.ts' . || echo "Not found with fd, trying rg..."
rg -l "grida-canvas-wasm.d.ts" . || echo "Not found"

echo "=== Checking WASM-related image functions ==="
rg -n "getImageBytes|getImageSize|addImage" -S --max-count=20

Repository: gridaco/grida

Length of output: 3380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking playground.tsx archivedir() call and backend gating ==="
rg -n "archivedir\(\)" -B 5 -A 5 editor/grida-canvas-hosted/playground/playground.tsx

echo "=== Finding __try_parse_hex16_from_image_src implementation ==="
rg -n "__try_parse_hex16_from_image_src" -A 10

echo "=== Checking WASM getImageBytes implementation ==="
head -200 crates/grida-canvas-wasm/lib/modules/canvas.ts | tail -100

echo "=== Checking what format is expected by WASM ==="
rg -n "getImageBytes" -B 2 -A 8 crates/grida-canvas-wasm/lib/modules/canvas.ts

Repository: gridaco/grida

Length of output: 7120


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full __try_parse_hex16_from_image_src implementation ==="
rg -n "__try_parse_hex16_from_image_src" -A 30 editor/grida-canvas/editor.ts | head -60

echo "=== Checking if DOM backend is even supposed to support archivedir ==="
rg -n "this.backend" -B 2 -A 2 | grep -E "(backend|dom|canvas)" | head -30

echo "=== Check if there's any backend === 'dom' or similar in codebase ==="
rg -n 'backend.*==.*["\x27](dom|canvas)["\x27]' --max-count=15

Repository: gridaco/grida

Length of output: 4943


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full __try_parse_hex16_from_image_src implementation ==="
sed -n '3278,3320p' editor/grida-canvas/editor.ts

echo "=== Verify archive() call context ==="
sed -n '2951,2960p' editor/grida-canvas/editor.ts

echo "=== Check if there's a check before archive() in other files ==="
rg -n "\.archive\(\)" -B 5 --max-count=10 | head -50

Repository: gridaco/grida

Length of output: 3423


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking if DOM backend has any image handling for persistence ==="
rg -n "this.backend.*dom" -A 5 -B 5 | head -80

echo "=== Verify loadImagesToWasmSurface gating pattern for reference ==="
sed -n '3302,3320p' editor/grida-canvas/editor.ts

echo "=== Check WASM TypeScript definitions are in sync ==="
cat crates/grida-canvas-wasm/lib/api.d.ts | grep -A 5 "getImageBytes\|addImage"

Repository: gridaco/grida

Length of output: 2295


archivedir() hard-requires mounted WASM but lacks backend checks in multiple call sites, contradicting its docstring

The method throws on non-canvas backend (line 2913), yet archive() (line 2951), playground.tsx line 654, playground.tsx line 689, and uxhost-menu.tsx line 115 all call it without gating by backend. The docstring explicitly states a DOM policy (keep external src as-is, no embed), but callers can invoke these methods on the DOM backend and hit the error. This is a UX regression.

Suggested fix: Return early for DOM backend with empty images and unmodified snapshot (matching the documented behavior), and only require WASM for the canvas backend:

Proposed fix
  public archivedir(): {
    document: grida.program.document.Document;
    images: Record<string, Uint8Array>;
  } {
    const snapshot = this.getSnapshot()
      .document as grida.program.document.Document;
    const images: Record<string, Uint8Array> = {};

    const usedSrcs = new dq.DocumentStateQuery(snapshot).image_srcs();
+   // DOM backend: do not embed bytes; keep src values as-is.
+   if (this.backend !== "canvas") {
+     return { document: structuredClone(snapshot), images };
+   }
    if (!this._m_wasm_canvas_scene) {
      throw new Error("`archivedir()` requires the WASM surface to be mounted.");
    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Prepares a persisted “archive directory” view of the current document state.
*
* This method is shared by both:
* - ZIP archive export (`.grida` via `io.archive.pack`)
* - OPFS persistence (playground `Cmd/Ctrl+S`)
*
* What it does:
* - Collects **used** image paint `src` references in the document.
* - For the WASM backend, resolves those runtime refs (`mem://...`) to bytes and a
* WASM-provided content hash (hex16), producing `images/<hash>.<ext>` entries.
* - Rewrites image paint `src` in the persisted document from runtime URL hash id.
*
* Notes / policy:
* - We intentionally **do not** compute SeaHash in JS; persisted image IDs come from WASM.
* - For DOM backend (hosted HTML flow), we keep external `src` values as-is and do not embed.
*
* TODO: support `thumbnail.png` generation/persistence alongside the document.
*/
public archivedir(): {
document: grida.program.document.Document;
/**
* Image files to persist under `images/`.
*
* Keys are filenames (e.g. "<hash>.png"), values are bytes.
*/
images: Record<string, Uint8Array>;
} {
const snapshot = this.getSnapshot()
.document as grida.program.document.Document;
const images: Record<string, Uint8Array> = {};
const usedSrcs = new dq.DocumentStateQuery(snapshot).image_srcs();
if (this.backend !== "canvas" || !this._m_wasm_canvas_scene) {
throw new Error(
"`archivedir()` requires the canvas/WASM backend to be mounted."
);
}
// Collect bytes keyed by hash, by reading them back from WASM.
for (const src of usedSrcs) {
const hashHex = Editor.__try_parse_hex16_from_image_src(src);
if (!hashHex) {
throw new Error(
`Cannot persist image paint src (expected res://images/<hex16>, mem://<hex16>, or <hex16>): "${src}"`
);
}
);
// Rust/WASM normalizes non-RID ids into `res://images/<hex16>`.
const bytes = this.__get_image_bytes_for_wasm(hashHex);
if (!bytes) {
throw new Error(
`Cannot persist image bytes (WASM missing resource for "${hashHex}")`
);
}
const mime = snapshot.images?.[hashHex]?.type;
const ext = (mime && mime.split("/")[1]) || "bin";
images[`${hashHex}.${ext}`] = bytes;
}
// Rewrite persisted image paint refs (runtime URL -> hash id) before encoding.
const persisted = structuredClone(snapshot);
Editor.__rewriteImagePaintSrcDeep(persisted, (src) => {
const hex = Editor.__try_parse_hex16_from_image_src(src);
return hex ?? undefined;
});
return { document: persisted, images };
}
public archive(): Blob {
const { document, images } = this.archivedir();
const blob = new Blob([io.archive.pack(document, images) as BlobPart], {
type: "application/zip",
});
return blob;
}
/**
* Prepares a persisted "archive directory" view of the current document state.
*
* This method is shared by both:
* - ZIP archive export (`.grida` via `io.archive.pack`)
* - OPFS persistence (playground `Cmd/Ctrl+S`)
*
* What it does:
* - Collects **used** image paint `src` references in the document.
* - For the WASM backend, resolves those runtime refs (`mem://...`) to bytes and a
* WASM-provided content hash (hex16), producing `images/<hash>.<ext>` entries.
* - Rewrites image paint `src` in the persisted document from runtime URL hash id.
*
* Notes / policy:
* - We intentionally **do not** compute SeaHash in JS; persisted image IDs come from WASM.
* - For DOM backend (hosted HTML flow), we keep external `src` values as-is and do not embed.
*
* TODO: support `thumbnail.png` generation/persistence alongside the document.
*/
public archivedir(): {
document: grida.program.document.Document;
/**
* Image files to persist under `images/`.
*
* Keys are filenames (e.g. "<hash>.png"), values are bytes.
*/
images: Record<string, Uint8Array>;
} {
const snapshot = this.getSnapshot()
.document as grida.program.document.Document;
const images: Record<string, Uint8Array> = {};
const usedSrcs = new dq.DocumentStateQuery(snapshot).image_srcs();
// DOM backend: do not embed bytes; keep src values as-is.
if (this.backend !== "canvas") {
return { document: structuredClone(snapshot), images };
}
if (!this._m_wasm_canvas_scene) {
throw new Error(
"`archivedir()` requires the WASM surface to be mounted."
);
}
// Collect bytes keyed by hash, by reading them back from WASM.
for (const src of usedSrcs) {
const hashHex = Editor.__try_parse_hex16_from_image_src(src);
if (!hashHex) {
throw new Error(
`Cannot persist image paint src (expected res://images/<hex16>, mem://<hex16>, or <hex16>): "${src}"`
);
}
// Rust/WASM normalizes non-RID ids into `res://images/<hex16>`.
const bytes = this.__get_image_bytes_for_wasm(hashHex);
if (!bytes) {
throw new Error(
`Cannot persist image bytes (WASM missing resource for "${hashHex}")`
);
}
const mime = snapshot.images?.[hashHex]?.type;
const ext = (mime && mime.split("/")[1]) || "bin";
images[`${hashHex}.${ext}`] = bytes;
}
// Rewrite persisted image paint refs (runtime URL -> hash id) before encoding.
const persisted = structuredClone(snapshot);
Editor.__rewriteImagePaintSrcDeep(persisted, (src) => {
const hex = Editor.__try_parse_hex16_from_image_src(src);
return hex ?? undefined;
});
return { document: persisted, images };
}
public archive(): Blob {
const { document, images } = this.archivedir();
const blob = new Blob([io.archive.pack(document, images) as BlobPart], {
type: "application/zip",
});
return blob;
}

Comment on lines +3380 to +3391
this.doc.reduce((state) => {
// Persistable image metadata is keyed by canonical content hash (hex16).
// Runtime refs use `res://images/<hex16>` (returned by WASM) and are stored in `ImagePaint.src` after registration.
state.document.images[hash] = {
url: hash,
width,
height,
bytes: data.byteLength,
type: type as grida.program.document.ImageType,
};
return state;
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid unconditional state.document.images[hash] = ... (can mark document dirty on asset-load, and can crash if missing map)

Line 3383 writes into state.document.images unconditionally:

  • If document.images can be absent on some documents, this can throw.
  • Even when present, setting the same metadata during archive/OPFS load can flip the “dirty” flag right after a reset (because it mutates the document), contradicting the loadImagesToWasmSurface docstring.
Proposed fix (init + only set when missing)
@@
     this.images.set(url, ref);
     this.doc.reduce((state) => {
       // Persistable image metadata is keyed by canonical content hash (hex16).
       // Runtime refs use `res://images/<hex16>` (returned by WASM) and are stored in `ImagePaint.src` after registration.
-      state.document.images[hash] = {
-        url: hash,
-        width,
-        height,
-        bytes: data.byteLength,
-        type: type as grida.program.document.ImageType,
-      };
+      state.document.images = state.document.images ?? ({} as any);
+      if (!state.document.images[hash]) {
+        state.document.images[hash] = {
+          url: hash,
+          width,
+          height,
+          bytes: data.byteLength,
+          type: type as grida.program.document.ImageType,
+        };
+      }
       return state;
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.doc.reduce((state) => {
// Persistable image metadata is keyed by canonical content hash (hex16).
// Runtime refs use `res://images/<hex16>` (returned by WASM) and are stored in `ImagePaint.src` after registration.
state.document.images[hash] = {
url: hash,
width,
height,
bytes: data.byteLength,
type: type as grida.program.document.ImageType,
};
return state;
});
this.doc.reduce((state) => {
// Persistable image metadata is keyed by canonical content hash (hex16).
// Runtime refs use `res://images/<hex16>` (returned by WASM) and are stored in `ImagePaint.src` after registration.
state.document.images = state.document.images ?? ({} as any);
if (!state.document.images[hash]) {
state.document.images[hash] = {
url: hash,
width,
height,
bytes: data.byteLength,
type: type as grida.program.document.ImageType,
};
}
return state;
});
🤖 Prompt for AI Agents
In `@editor/grida-canvas/editor.ts` around lines 3380 - 3391, The reducer
unconditionally mutates state.document.images which can throw if images is
undefined and can mark the document dirty even when metadata already exists;
inside the this.doc.reduce callback (used by loadImagesToWasmSurface) ensure
state.document.images is initialized if missing (create an empty object) and
only assign state.document.images[hash] when that key is absent (i.e., skip
writing if metadata already exists) so you avoid errors and unnecessary
mutations that flip the dirty flag.

Comment on lines +1 to +4
import { editor } from "..";
import type grida from "@grida/schema";
import assert from "assert";
import cg from "@grida/cg";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Where does query/.. point? =="
python - <<'PY'
import os
print(os.path.normpath("editor/grida-canvas/query/.."))
PY

echo "== Find editor/grida-canvas index entrypoints =="
fd -a '^index\.(ts|tsx|js|mjs|cjs)$' editor/grida-canvas || true

echo "== Check whether editor/grida-canvas/index.* pulls in ./editor or ./query (cycle risk) =="
fd -a '^index\.(ts|tsx|js|mjs|cjs)$' editor/grida-canvas -0 | xargs -0 -I{} rg -n "from\s+['\"]\./(editor|query)\b" -S {} || true

echo "== Confirm editor.ts imports dq =="
rg -n "from\s+['\"]@/grida-canvas/query['\"]|from\s+['\"]\./query" -S editor/grida-canvas/editor.ts || true

echo "== Locate resolvePaints export location (candidate to import directly to avoid cycle) =="
rg -n "resolvePaints" editor/grida-canvas -S

Repository: gridaco/grida

Length of output: 143


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Step 1: Verify path resolution =="
python3 - <<'PY'
import os
print("query/.. resolves to:", os.path.normpath("editor/grida-canvas/query/.."))
PY

echo "== Step 2: List index files in editor/grida-canvas =="
find editor/grida-canvas -maxdepth 1 -type f -name "index.*" 2>/dev/null | head -5

echo "== Step 3: Check if main index exists and its imports =="
if [ -f "editor/grida-canvas/index.ts" ]; then
  echo "--- editor/grida-canvas/index.ts ---"
  head -30 editor/grida-canvas/index.ts | grep -E "from|import" || true
fi

echo "== Step 4: Check editor.ts imports (targeted search) =="
if [ -f "editor/grida-canvas/editor.ts" ]; then
  echo "--- Checking editor.ts for query imports ---"
  rg "query" editor/grida-canvas/editor.ts -n -C 1 --max-count=10 || echo "No matches"
fi

echo "== Step 5: Check query/index.ts import of editor (line 1) =="
if [ -f "editor/grida-canvas/query/index.ts" ]; then
  echo "--- First 10 lines of query/index.ts ---"
  head -10 editor/grida-canvas/query/index.ts
fi

Repository: gridaco/grida

Length of output: 2083


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check if editor.i file exists =="
ls -la editor/grida-canvas/ | grep -E "editor\.(ts|i|js)" || echo "Checking with find..."
find editor/grida-canvas -maxdepth 1 -name "editor.*" -type f

echo "== Check the actual export chain =="
echo "--- index.ts exports from: ---"
rg "export.*editor" editor/grida-canvas/index.ts -A 2 -B 2

echo "== Check editor.ts structure (first 50 lines) =="
head -50 editor/grida-canvas/editor.ts | grep -E "^(import|export|class|function)" | head -20

echo "== Verify if query is imported at top-level or lazy-loaded =="
rg "import.*dq|from.*query" editor/grida-canvas/editor.ts -B 2 -A 2 | head -20

Repository: gridaco/grida

Length of output: 1590


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check editor.i.ts imports (first 50 lines) =="
head -50 editor/grida-canvas/editor.i.ts | grep -E "^(import|export)" | head -30

echo "== Check if editor.i imports from editor or query =="
rg "import.*from.*['\"].*editor['\"]|import.*from.*['\"].*query" editor/grida-canvas/editor.i.ts -n | head -20

echo "== Determine what query/index.ts actually uses from editor =="
rg "editor\." editor/grida-canvas/query/index.ts -n | head -10

Repository: gridaco/grida

Length of output: 1160


Circular dependency confirmed: query/index.ts and editor.i.ts have bidirectional runtime imports

Line 1 imports editor at runtime. The exported editor module (editor.i.ts) imports dq from this module at line 11, creating a circular dependency. Both imports are at the top level, meaning initialization order determines whether editor.resolvePaints() (used at lines 638, 671) is defined when query/index.ts executes. Depending on bundler behavior, this can cause editor.resolvePaints to be undefined at runtime.

Resolve by either:

  • Converting the editor import to a local/lazy import inside the functions that use it (lines 551, 638, 671)
  • Moving query logic that depends on editor.resolvePaints to editor.i.ts or a separate module that doesn't import from query
🤖 Prompt for AI Agents
In `@editor/grida-canvas/query/index.ts` around lines 1 - 4, query/index.ts
creates a circular runtime import with editor.i.ts because it imports editor at
module top-level while editor.i.ts imports dq from this module; fix by removing
the top-level import of editor and performing a local (lazy) require/import for
the editor symbol only inside the functions that call editor.resolvePaints (the
call sites around the current lines labeled 551, 638, 671), e.g., inside the
functions that need resolvePaints do const { editor } = await import("..") or
require("..") and then call editor.resolvePaints; alternatively, move any query
code that depends on editor.resolvePaints into editor.i.ts or a new module that
does not import dq to eliminate the bidirectional dependency.

Comment on lines +616 to +668

/**
* Returns all image paints used across the document (fills + strokes).
*
* This is the right primitive to build "persist resources" steps on top of.
*/
private image_paints(): Array<{
node_id: string;
target: "fill" | "stroke";
paint: cg.ImagePaint;
}> {
const out: Array<{
node_id: string;
target: "fill" | "stroke";
paint: cg.ImagePaint;
}> = [];

for (const node_id of this.nodeids) {
const node = this.nodes[node_id];
if (!node) continue;

for (const target of ["fill", "stroke"] as const) {
const { paints } = editor.resolvePaints(
node as grida.program.nodes.UnknownNode,
target,
0
);
for (const paint of paints) {
if (cg.isImagePaint(paint)) {
out.push({ node_id, target, paint });
}
}
}
}

return out;
}

/**
* Returns unique image `src` references used by image paints.
*
* - Persisted: typically `hex16` (content hash id)
* - Runtime: typically `blob:` or `mem://...`
*/
image_srcs(): string[] {
return Array.from(
new Set(
this.image_paints()
.map((p) => p.paint.src)
.filter(Boolean)
)
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Verify whether image nodes exist and how they're shaped in schema usage.
rg -n "type:\s*\"image\"|interface\s+ImageNode|ImageNode" -S

Repository: gridaco/grida

Length of output: 11002


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check ImageNode interface definition and src property
echo "=== ImageNode interface definition ==="
sed -n '2232,2245p' packages/grida-canvas-schema/grida.ts

echo ""
echo "=== ComputedImageNode interface definition ==="
sed -n '2246,2250p' packages/grida-canvas-schema/grida.ts

echo ""
echo "=== Search for Editor.archivedir() and image_srcs() usage ==="
rg -n "archivedir|image_srcs" editor/grida-canvas/editor.ts -B 2 -A 3 | head -80

Repository: gridaco/grida

Length of output: 1465


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find ISourceValue interface definition
echo "=== ISourceValue interface ==="
rg -n "interface ISourceValue|ISourceValue" packages/grida-canvas-schema -A 5 | head -30

echo ""
echo "=== Check DocumentStateQuery class definition ==="
rg -n "class DocumentStateQuery" editor/grida-canvas/query/index.ts -A 5

echo ""
echo "=== View image_srcs() and image_paints() full implementation ==="
sed -n '616,668p' editor/grida-canvas/query/index.ts

Repository: gridaco/grida

Length of output: 3701


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check VideoNode interface ==="
sed -n '2284,2305p' packages/grida-canvas-schema/grida.ts

echo ""
echo "=== Search for video node usage in documents ==="
rg -n 'type:\s*"video"' packages/grida-tree/__tests__/ editor/ | head -20

echo ""
echo "=== Check if image_srcs() name is misleading (should it include video?) ==="
rg -n "image_srcs|image src" editor/grida-canvas/query/index.ts | head -10

Repository: gridaco/grida

Length of output: 1416


Add image and video nodes to image_srcs() for complete persistence coverage

image_srcs() is used by Editor.archivedir() for persistence, but currently scans only fill/stroke paints. The method misses image nodes and video nodes (both with src properties), risking incomplete archives/OPFS saves.

Proposed fix (extend to include image and video node srcs)
@@
     image_srcs(): string[] {
-      return Array.from(
-        new Set(
-          this.image_paints()
-            .map((p) => p.paint.src)
-            .filter(Boolean)
-        )
-      );
+      const srcs = new Set<string>();
+
+      // Image paints (fills + strokes)
+      for (const p of this.image_paints()) {
+        if (p.paint.src) srcs.add(p.paint.src);
+      }
+
+      // Image and video nodes
+      for (const node_id of this.nodeids) {
+        const node = this.nodes[node_id];
+        if (!node) continue;
+        if (node.type === "image" || node.type === "video") {
+          const src = (node as any).src;
+          if (typeof src === "string" && src) srcs.add(src);
+        }
+      }
+
+      return Array.from(srcs);
     }
🤖 Prompt for AI Agents
In `@editor/grida-canvas/query/index.ts` around lines 616 - 668, image_srcs()
currently collects srcs only from image paints via image_paints(), missing
node-level images/videos; update image_srcs() to also iterate this.nodeids and
collect src from nodes that are image or video types (e.g., check node.type ===
"image" || node.type === "video" or use cg.isImageNode / cg.isVideoNode if
available) and include their node.src values (filtering falsy) into the Set so
archivedir()/persistence covers both paint-based and node-based media sources.

@softmarshmallow softmarshmallow merged commit 0166de2 into main Jan 16, 2026
10 checks passed
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