Skip to content

Fix LoadSeuratRds losing on-disk layers after an object is moved or shared - #297

Closed
BenjaminDEMAILLE wants to merge 2 commits into
satijalab:mainfrom
BenjaminDEMAILLE:fix/loadseuratrds-portable-paths
Closed

BenjaminDEMAILLE wants to merge 2 commits into
satijalab:mainfrom
BenjaminDEMAILLE:fix/loadseuratrds-portable-paths

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown

Summary

SaveSeuratRds() records where each on-disk layer (BPCells IterableMatrix, DelayedMatrix, ...) lives, and LoadSeuratRds() reopens it from that record. But the record was only ever interpreted from the current working directory, so both ways of saving break as soon as the object is shared or reorganized:

  • relative = TRUE writes paths relative to the .Rds, so loading from any other working directory finds nothing;
  • relative = FALSE (the default) writes absolute paths, so moving or renaming the folder holding the .Rds and its stores finds nothing.

In both cases the load warns Cannot find any of the layer files specified and returns an object with no layers at all, which is easy to miss.

Reproducer

library(SeuratObject); library(BPCells)
m <- as(matrix(rpois(2400, 1.3), 60, 40), "dgCMatrix")
dimnames(m) <- list(paste0("g", 1:60), paste0("c", 1:40))
store <- tempfile(); write_matrix_dir(m, store)
obj <- CreateSeuratObject(counts = open_matrix_dir(store))

d1 <- tempfile(); dir.create(d1)
SaveSeuratRds(obj, file.path(d1, "o.Rds"), move = TRUE, relative = TRUE)

d2 <- tempfile(); file.rename(d1, d2)          # share / reorganize
setwd(tempdir())                                # load from anywhere else
o <- LoadSeuratRds(file.path(d2, "o.Rds"))
#> Warning: Cannot find any of the layer files specified
Layers(o)
#> NULL

The same happens with relative = FALSE after the folder is moved.

Fix

Resolve each cached path against the directory holding the .Rds before checking whether it exists. .ResolveLayerPaths() tries, in order:

  1. the recorded path as-is,
  2. the recorded path relative to the .Rds directory,
  3. its basename within the .Rds directory.

Objects that have not moved keep loading through the first branch, so existing behaviour is unchanged. Step 2 fixes relative = TRUE; step 3 fixes an absolute path whose store travelled alongside the .Rds, which is exactly the layout SaveSeuratRds(move = TRUE) creates.

Tests

New tests/testthat/test_save_rds.R covers all three cases (relative after a move from another working directory, absolute after a move, and an object that has not moved). Against main it fails 6 assertions; with this change all 11 pass. Guarded with skip_if_not_installed("BPCells").

Notes

🤖 Generated with Claude Code

SaveSeuratRds() records where each on-disk layer lives and LoadSeuratRds()
reopens it from that record, but the record was only ever interpreted from
the current working directory. Both ways of saving therefore broke as soon
as the object was shared or reorganized:

- relative = TRUE writes paths relative to the rds, so loading from any
  other working directory found nothing;
- relative = FALSE writes absolute paths, so moving or renaming the folder
  holding the rds and its stores found nothing.

In both cases LoadSeuratRds() warned "Cannot find any of the layer files
specified" and returned an object with no layers at all.

Resolve each cached path against the directory holding the rds file before
checking it: try the recorded path as-is, then relative to that directory,
then by name within it. Objects that have not moved keep loading through
the first branch, so existing behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BenjaminDEMAILLE

BenjaminDEMAILLE commented Aug 30, 2026 •

Copy link
Copy Markdown
Author

cc @anashen

Small one, and it is a genuine bug rather than a feature: LoadSeuratRds() resolves its cached on-disk layer paths against the current working directory instead of the directory holding the .Rds. So relative = TRUE breaks whenever you load from anywhere else, and relative = FALSE breaks whenever the folder is moved. In both cases the load emits one warning and returns an object with no layers at all, which is easy to miss.

One helper plus one call site, no new dependency. The new test file fails 6 assertions against main and passes 11 with the change, and R CMD check on the branch is Status: OK with no NOTEs.

Related Seurat-side PR: satijalab/seurat#10452.


Outside contribution: I am not a member of the Seurat dev team, and this comment is not an official answer from the maintainers.

The resolver handles comma-separated path entries, since some on-disk
classes record more than one file per layer, but nothing exercised that
branch. Add unit tests for it, plus the cases that must not be disturbed:
a component that already resolves is returned byte-identical, an
unresolvable one is returned unchanged rather than rewritten, and NA,
empty and dir = NULL inputs pass straight through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BenjaminDEMAILLE

Copy link
Copy Markdown
Author

Superseded by #320, which groups the on-disk layer PRs into one, since they are the same round trip: join, save, reload. The commit is carried over unchanged; closing here to keep the queue down rather than because anything was wrong with it.

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