This is the normative description of the files shinysnap writes and
reads. The current format version is 1. Readers accept every earlier
version and convert on read; the number is bumped only for incompatible
changes. The app$version field is for the app’s
migrations (the migrate hook of
snap_restore()); the format field is for the
package’s.
In R, a snapshot is a plain list of class shinysnap:
library(shinysnap)
snap <- snap_unserialize('{
"format": 1,
"app": {"name": "myapp", "version": "2.4.1"},
"created": "2026-09-16T18:22:03Z",
"inputs": {"n": 100, "rate": 0.025},
"values": {"prefs": {"digits": 3}},
"bindings": {"n": "shiny.sliderInput", "rate": "shiny.numberInput"}
}')
str(unclass(snap))
#> List of 9
#> $ format : int 1
#> $ app :List of 2
#> ..$ name : chr "myapp"
#> ..$ version: chr "2.4.1"
#> $ created : chr "2026-09-16T18:22:03Z"
#> $ producer : NULL
#> $ inputs :List of 2
#> ..$ n : int 100
#> ..$ rate: num 0.025
#> $ values :List of 1
#> ..$ prefs:List of 1
#> .. ..$ digits: int 3
#> $ bindings : Named chr [1:2] "shiny.sliderInput" "shiny.numberInput"
#> ..- attr(*, "names")= chr [1:2] "n" "rate"
#> $ attachments: list()
#> $ meta : list()| field | content |
|---|---|
format |
the format version, an integer |
app |
name and version of the app; either may be
NULL |
created |
when the state was captured, ISO 8601 in UTC |
producer |
versions of shinysnap, shiny, and R that wrote the file |
inputs |
named list of input values, keyed by fully namespaced id |
values |
named list of server-side values |
bindings |
named character vector, input id to client binding name |
attachments |
file records, present in bundles only |
meta |
free-form, user-supplied |
inputs and values hold ordinary R values,
exactly what input$x returns. The encoding below is applied
at write and read time only.
A file is one JSON object with the keys above, in that order,
pretty-printed with two-space indentation and a trailing newline, in
UTF-8. Input ids are written in the order captured, which
snap_take() sorts, so files of the same state are
byte-identical and diffs in version control are meaningful. A container
is written on one line when it fits in 80 columns and one element per
line otherwise.
NULL is null.{"$type": "<storage>", "value": []}, because a bare
[] has no type.true/false, strings are
escaped per RFC 8259 with non-ASCII kept as UTF-8, integers are plain
digit runs, and doubles are written with the fewest digits that read
back to the same value (zmij::format_double()). A finite
double always contains a . or an e, so
1 is an integer and 1.0 a double.NA inside a vector of length greater than one is
null at that position; the other elements fix the type. A
vector that is entirely NA, including a single
NA, is a typed wrapper with null values, so
that NULL, NA, and NA_character_
stay distinct."inf",
"-inf", and "NaN" inside a double
wrapper, which is used only when such a value is present.{"$type": "<storage>", "names": [...], "value": [...]}.Date is
{"$type": "Date", "value": ["2024-01-01", null]};
POSIXct is
{"$type": "POSIXct", "tz": "UTC", "value": ["2024-01-01T10:00:00.000Z"]},
written in the stored time zone at millisecond precision (the
tz key is absent when the value has no time zone attribute,
and the offset is Z for UTC and for the session time zone,
which is formatted in UTC); difftime is
{"$type": "difftime", "units": "secs", "value": [...]}; a
factor is
{"$type": "factor", "levels": [...], "value": ["a", null]}
with an "ordered": true flag when ordered.{"$type": "array", "storage": "double", "dim": [2, 3], "dimnames": [["a", "b"], null], "value": [...]}
with the values in column-major order ("matrix" is accepted
as an alias on read).{"$type": "data.frame", "nrow": 3, "columns": {"x": ..., "y": ...}},
with a "row.names" array when the row names are not the
automatic ones. Tibbles are written as data frames.{"$type": "list", "names": [...], "value": [...]} with
names omitted when absent, except that an unnamed list with
at least one element that is not a scalar is written as a bare array,
which reads back as a list.unsupported = "rds"
it becomes
{"$type": "rds", "class": [...], "base64": "..."} (in a
bundle,
{"$type": "rds", "class": [...], "path": "objects/1-values_fit.rds"}).
Reading these requires trust = TRUE; otherwise they decode
to NULL with one warning that lists them.Keys starting with $ are reserved.
cat(snap_serialize(list(inputs = list(
n = 1L, x = 1, sum = 0.1 + 0.2, flag = c(TRUE, NA),
empty = character(0), missing = NA, extremes = c(0.5, Inf),
named = c(a = 1L, b = 2L),
when = as.Date("2024-01-15"),
stamp = as.POSIXct("2024-01-01 10:00:00.5", tz = "UTC"),
level = factor("b", levels = c("a", "b")),
M = matrix(1:6, 2, dimnames = list(c("r1", "r2"), NULL)),
df = data.frame(x = 1:2, y = c("a", "b")),
mixed = list(1, "a"),
nested = list(a = 1, b = list(c = NULL))
))))
#> {
#> "format": 1,
#> "app": {"name": null, "version": null},
#> "created": "2026-09-18T01:57:52Z",
#> "producer": {"shinysnap": "0.1.0", "shiny": "1.14.0", "r": "4.6.1"},
#> "inputs": {
#> "n": 1,
#> "x": 1.0,
#> "sum": 0.30000000000000004,
#> "flag": [true, null],
#> "empty": {"$type": "character", "value": []},
#> "missing": {"$type": "logical", "value": [null]},
#> "extremes": {"$type": "double", "value": [0.5, "inf"]},
#> "named": {"$type": "integer", "names": ["a", "b"], "value": [1, 2]},
#> "when": {"$type": "Date", "value": ["2024-01-15"]},
#> "stamp": {
#> "$type": "POSIXct",
#> "tz": "UTC",
#> "value": ["2024-01-01T10:00:00.500Z"]
#> },
#> "level": {"$type": "factor", "levels": ["a", "b"], "value": ["b"]},
#> "M": {
#> "$type": "array",
#> "storage": "integer",
#> "dim": [2, 3],
#> "dimnames": [["r1", "r2"], null],
#> "value": [1, 2, 3, 4, 5, 6]
#> },
#> "df": {
#> "$type": "data.frame",
#> "nrow": 2,
#> "columns": {"x": [1, 2], "y": ["a", "b"]}
#> },
#> "mixed": {"$type": "list", "value": [1.0, "a"]},
#> "nested": {"a": 1.0, "b": {"c": null}}
#> },
#> "values": {},
#> "bindings": {},
#> "meta": {}
#> }The reader parses the text with jsonlite
(simplifyVector = FALSE) and walks the tree. Arrays whose
elements are all scalars or null become atomic vectors,
typed from the non-null elements (integer when every number parsed as an
integer, double otherwise); mixing kinds is an error. Objects with a
$type key go through the typed decoders; other objects
become named lists; arrays with a non-scalar element become unnamed
lists. A $type the reader does not know is an error naming
the id, unless unknown_types = "keep" keeps the raw value.
Duplicate keys are an error.
The round-trip contract: for every supported type, writing and
reading gives a value identical() to the original, and
snap_read(snap_write(x)) is identical to x
except for producer, which the writer always stamps.
.zip)A bundle is a zip archive with:
manifest.json: exactly the JSON above, with an
attachments section. Each entry is a file record:
{"$type": "file", "name": "data.csv", "size": 1234, "type": "text/csv", "path": "attachments/upload/data.csv"};
the fields are arrays when a fileInput() held several
files.attachments/<id>/<file name>: the uploaded
files. Names are reduced to A-Z a-z 0-9 . _ - and
disambiguated with a counter when they repeat.objects/<n>-<path>.rds: values written with
unsupported = "rds".On read, the archive is checked before extraction: entries with
.. or absolute paths are refused, and the uncompressed
total must stay under
getOption("shinysnap.max_bundle_bytes", 100 * 1024^2). It
is then extracted into a fresh temporary directory; the manifest’s paths
must point inside it, under attachments/ or
objects/, at regular files.
snap_attachment(x, id) returns the extracted paths. The
snapshot’s inputs never contain file input values, because
a browser’s file input cannot be set programmatically; restore hooks can
read the attachment instead.
upload <- tempfile(fileext = ".csv")
writeLines(c("x,y", "1,2"), upload)
snap <- list(
inputs = list(n = 1L),
attachments = list(data = list(
name = "data.csv", size = file.size(upload), type = "text/csv", datapath = upload
))
)
bundle <- tempfile(fileext = ".zip")
snap_write(snap, bundle)
back <- snap_read(bundle)
readLines(snap_attachment(back, "data"))
#> [1] "x,y" "1,2".rds formatsnap_write(format = "rds") stores the R object with
saveRDS(). It is neither readable nor safe across versions,
and snap_read() refuses it unless
trust = TRUE, because unserializing a file runs arbitrary
code paths. It exists for people who really want it.