Skip to content

feat(preview): render XLSX spreadsheets in the file-preview overlay - #502

Open
aakhter wants to merge 8 commits into
Ark0N:masterfrom
aakhter:pr/xlsx-preview
Open

aakhter wants to merge 8 commits into
Ark0N:masterfrom
aakhter:pr/xlsx-preview

Conversation

@aakhter

@aakhter aakhter commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

What

.xlsx files were download-only. This adds a read-only preview in the file-preview overlay: sheet tabs, number formats, merged cells and theme colours, virtualized so large sheets scroll smoothly. It works for workspace files, attachments, and xlsx paths printed in the terminal (added to the file-path link pattern and FILE_PREVIEW_EXTENSIONS). .xls and .ods stay download-only, since ExcelJS only reads xlsx; tests pin both.

How

  • All parsing happens in the browser, in a Web Worker (spreadsheet-preview-worker.js + spreadsheet-xlsx-core.js) using exceljs@4.4.0 and fflate@0.8.3 (both MIT, pinned exactly as devDependencies and copied into vendor/ by postinstall and the build, like the other vendored bundles). The server does no parsing.
  • Server side xlsx only joins the existing attachment allowlist and file-content classification, so every request still goes through the existing confinement (resolveFileTarget, resolveServableAttachmentPath). There is no new path handling. ?preview=true is capped at 10 MB (413 above it) on both file-raw and the attachment raw route; downloads are unchanged.
  • Overlay wiring is two small methods on the existing preview (_openSpreadsheetPreview / _disposeSpreadsheetPreview), torn down from _stopFilePreviewMedia on open and close. The worker is created via CodemanBase.url and loads its scripts by relative URL, so --base-url mounts work.

Safety

The workbook is untrusted input:

  • Checked before ExcelJS loads (admitXlsx): at most 5000 ZIP entries, 64 MB inflated in total, 32 MB per entry, a 100:1 compression ratio, 50 worksheets, 250k cells (100k per sheet), 5000 merges per sheet and 5000 styles. Encrypted and ZIP64 files are refused. There is also a 20 s timeout.
  • Rendering: cell text and sheet names are written with textContent. The generated style block only accepts validated #rrggbb colours and a fixed keyword set. At most 2500 cells are drawn per tile.
  • Nothing is evaluated or fetched: formulas show their cached result (or the formula text), and external links, charts, drawings and macros only produce a "not shown" notice.

Cost

Page load gains only spreadsheet-preview.js (5.0 KB gz). Opening a spreadsheet then loads the worker (3.0 KB), core (7.4 KB), fflate (12.5 KB) and exceljs (256 KB gz, 948 KB raw), about 284 KB gz in total. ExcelJS is only fetched after the workbook passes the checks. check:public-assets enforces a 1.1 MB vendor budget, and a content-hash SPREADSHEET_ASSET_VERSION cache-busts the worker.

dependency-security.test.ts gains one exact exemption: exceljs pins uuid@8.3.2 (our own uuid stays >= 14). The advisory is MODERATE, outside that suite's CRITICAL/HIGH policy, and covers v3/v5/v6 with a caller-supplied buffer, while exceljs only calls v4(). The browser also loads exceljs's own prebuilt dist bundle, and nothing server-side imports exceljs.

Testing

  • 43 new tests: xlsx core 20, worker 7, renderer/overlay 10, assets 5, plus 1 Chromium test under a strict CSP (added to BROWSER_TEST_GLOBS). Also 6 new route tests across file-routes and the attachment path guard.
  • Mutation-checked. Each of these fails a test:
    • textContent swapped for innerHTML
    • xlsx removed from the allowlist
    • the classification change reverted
    • the preview cap removed
    • the compression-ratio cap removed
    • the workbook checks skipped
    • no dispose on close
    • the formula-text path broken
  • Related suites pass unchanged, including file-routes, the attachment path guard, sw-precache, base-path and dependency-security.
  • typecheck, lint, check:frontend-syntax, format:check, check:public-assets, check:lockfile and build are clean.
  • Checked in a real browser on an isolated instance: both sheet tabs rendered, a cell containing <img onerror> displayed as text with no image element created, no console errors, and none of the four preview requests were made until a spreadsheet was opened.

xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
@Ark0N

Ark0N commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for this, @aakhter. It adds a read-only XLSX preview to the file-preview overlay, parsed entirely in a browser worker, and the overall design is exactly right: no server-side parsing, no new path handling, lazy worker-only vendor bundles, textContent rendering, an allowlisted style block, --base-url support and a content-hashed cache-bust token. Typecheck, lint, format, the asset checks, the full npm test gate and your Chromium test all pass here.

While testing it with real ExcelJS workbooks through the worker I hit three bugs that need fixing before merge:

  1. Date, rich-text, hyperlink and error cells display wrong (src/web/public/spreadsheet-xlsx-core.js:309). ExcelJS turns date-formatted cells into JS Date objects on load, so String(value) shows Mon Jan 15 2024 01:00:00 GMT+0100 (...), and in TZ=America/New_York the same cell reads Sun Jan 14 2024 ..., a day early. Rich text ({richText}), hyperlinks ({text, hyperlink}), error values ({error}) and formulas with an error result all render [object Object]. Please normalize these shapes before formatting (format Date from its UTC components per the numFmt, join richText runs, use .text / .error, recurse on result for formula and sharedFormula) and add worker round-trip tests for each, one of them under a negative-offset TZ.

  2. Filtered or dense sheets fail to preview (src/web/public/spreadsheet-preview-worker.js:179-188). sendTile() includes hidden rows, which are 0 px tall, so the viewport spans all of them, and above 2500 cells it throws tile-limit, which replaces the whole grid with an error. A 1000 x 6 sheet with 980 rows hidden by a filter fails at the renderer's default viewport, and so does a 60 x 60 filled block. Please skip hidden rows and columns in sendTile() and return a truncated tile with a warning at the cap instead of throwing (the renderer already slices to 2500). A hidden-rows worker test would pin it.

  3. admitXlsx() can be bypassed with overlapping ZIP entries (src/web/public/spreadsheet-xlsx-core.js:153-207). Admission follows local headers in file order, while ExcelJS (JSZip) follows the central directory, and the name-count check does not tie the two together. A crafted file with a stored entry that hides a full sheet1.xml, plus a small decoy sheet1.xml later in the stream, was admitted as 1 cell and 611 KB inflated; the worker then parsed 300,000 cells. Impact stays in the viewer's tab, but the PR and the new CLAUDE.md line state that admission caps the ZIP before ExcelJS runs. The simplest robust fix is to hand ExcelJS a STORE-only archive rebuilt from the entries admission already inflated (fflate.zipSync(entries, { level: 0 })); rejecting central entries whose local extents overlap also works. Please add that fixture as a regression test.

Two smaller things that fit in the same round:

  • Row and column headings take their size from CSS (64 x 20 px, styles.css:19176) rather than the axis math (spreadsheet-preview.js:216-234), so they misalign with custom widths and heights, including column B and row 2 of your own fixture. Setting width/height from the same axisOffset differences as the cells fixes it.
  • The new XLSX rule in CLAUDE.md points at docs/architecture-invariants.md#file-path-links-terminal--response-viewer, which was not updated; a short paragraph there (admission caps, worker-only vendor loading, SPREADSHEET_ASSET_VERSION) keeps the two in step. The attachments panel help (panels-ui.js:4793) and codeman attach error text (src/cli.ts:114) could also list .xlsx now.

Everything else (the route changes, the allowlist addition, the packaging and the tests) is in good shape, so once these land it is ready to merge. Thanks again for the careful work on this.

- Normalize the value shapes ExcelJS loads before formatting: Date cells are
  formatted from their serial (UTC), so they no longer render as a local-time
  string a day early at negative UTC offsets; rich text joins its runs,
  hyperlinks show their text, error values show the error, and formula and
  shared-formula results (including error results) recurse. Excel serials are
  rounded to whole milliseconds so 00:05 no longer shows as 00:04.
- sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a
  truncated tile with a warning instead of failing the whole preview.
- ExcelJS now parses a STORE-only archive rebuilt from exactly the entries
  admitXlsx() inflated and counted, never the fetched bytes. Admission walks
  local headers while JSZip reads the central directory, so overlapping
  entries could show the two readers different sheets. A duplicate local
  entry name is refused. The theme fallback reads the admitted entry too.
- Row and column headings take their size from the same axis math as cells.
- Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION
  rules in architecture-invariants, and list .xlsx in the attachments panel
  help and the `codeman attach` error text (built from the accepted list).
@aakhter

aakhter commented Sep 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, and for testing with real ExcelJS workbooks. All of it is addressed in 4edb7b8.

  1. Cell values. Dates now format from their Excel serial (UTC), so the day-early bug is gone. Rich text joins its runs, hyperlinks show their text, errors show the error string, and formula/sharedFormula recurse on result, including error results. The worker round-trip tests build their workbooks with ExcelJS and run under TZ=America/New_York (the file asserts the zone took effect). They also caught a second bug: excelDate truncated fractional milliseconds, so 00:05 showed as 00:04. It rounds now.
  2. Hidden rows and dense sheets. sendTile() skips hidden rows and columns, and at the cap it returns a truncated tile with a warning instead of throwing. Your 1000x6 sheet with 980 rows filtered out and the 60x60 block are both tests, and both failed with tile-limit on the old code.
  3. Admission bypass. I went with your preferred fix: ExcelJS only ever gets buildAdmittedArchive(), a STORE-only fflate.zipSync(entries, { level: 0 }) of the entries admission inflated, and a name streamed twice is refused. The regression fixture is your construction: a stored carrier entry hiding a full 11,000x10 sheet1.xml, a one-cell decoy later in the stream, and a central directory pointing inside the carrier. On the old code admission counted 1 cell while the worker parsed 110k. Handing ExcelJS the original bytes again fails that test. The cost is one extra copy of the inflated entries, still bounded by the 64 MB cap.

The small ones are done too: headings are sized from the same axisOffset math as the cells (the Chromium test checks column B and row 2 against their cells), docs/architecture-invariants.md has a paragraph under the anchor CLAUDE.md points to, and .xlsx is listed in the attachments panel help and the codeman attach error text. Both of those are now built from one DOCUMENT_ATTACHMENT_EXTENSIONS list, with a test that catches drift.

@Ark0N

Ark0N commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Thanks again, @aakhter. This PR adds a read-only XLSX preview to the file-preview overlay, parsed in a browser worker behind admission caps. I checked every item from the first round at 4edb7b8 and all of them are fixed: the date and value shapes (with the TZ test), hidden rows and the truncated tile, the STORE-only rebuilt archive with its overlapping-entry fixture, heading sizes, and the docs and help text. Typecheck, lint, format, the asset checks, the full npm test gate (436 files, 8355 tests) and your Chromium test all pass here.

One more thing needs fixing before merge, in the same family as the round 1 admission bypass:

  1. Admission does not bound what ExcelJS allocates (src/web/public/spreadsheet-xlsx-core.js:133, parsed at src/web/public/spreadsheet-preview-worker.js:128). createXmlCounter counts <c and <mergeCell occurrences, but ExcelJS 4.4.0 expands three constructs into one object per cell or column at load time:

    • every cell inside a merge range (Worksheet._mergeCellsInternal),
    • every address in a <dataValidation sqref> (DataValidationsXform.parseClose),
    • every index up to <col max> (Column.fromModel, with no clamp to 16384).

    I ran your worker and core through the worker-test harness. A 6.5 KB file that admission counts as 1 cell produced:

    • <mergeCell ref="A1:CV30000"/>: 10.4 s and 1.05 GB of heap,
    • <dataValidation sqref="A1:XFD1048576">: still running after 60 s,
    • <col min="1" max="3000000"/>: 511 MB.

    It also hits ordinary files: a dropdown validation on five whole columns (B2:F1048576) takes 6.9 s and 448 MB. Please:

    • load with xlsx.load(admitted, { ignoreNodes: ['dataValidations'] }). The preview never shows validations, and this takes the full-sheet case from over 60 s to 32 ms here.
    • In createXmlCounter, add each <mergeCell ref> area (rows x cols, via parseRange) to the per-sheet and total cell counts, and refuse a ref that does not parse.
    • Refuse a <col> whose min or max exceeds 16384.
    • Add a worker-harness fixture for each of the three.

Two smaller ones that fit in the same round:

  • src/web/public/spreadsheet-preview.js:85: axisOffset walks the whole override list on every call, and renderTile calls it about six times per cell. On a 100k-row sheet with an explicit height on every row, one tile near the bottom costs about 960 ms on the main thread, and that repeats on every scroll frame. A prefix-sum array per axis, built in selectSheet() and binary-searched, makes each call O(log n).
  • src/web/public/spreadsheet-xlsx-core.js:194: the ratio check divides by the central directory's declared compressedSize, which nothing validates. A file that sets it to 0x7fffffff passes the ratio check (the 32/64 MB caps still hold). Refusing a declared size larger than the file makes the ratio cap real.

At merge time I will move the SPREADSHEET_ASSET_VERSION hash comparison into test/spreadsheet-assets.test.ts, because CI does not run check:public-assets and the vitest test only checks the token's shape. The late-response race in openFilePreview, where a slow file-content reply can start a preview after the overlay has closed, predates this PR and affects every preview type, so I will fix it separately.

Once the admission fix lands, this is ready to merge. Thanks for the careful work on both rounds.

ExcelJS 4.4.0 expands three constructs into one object per cell or column
at load time, so a few KB admitted as one cell could cost a gigabyte:

- a <mergeCell> now costs its full area against the per-sheet and total
  cell caps, and a ref that does not parse is refused
- a <col> whose min or max is past 16384 is refused
- the worker loads with ignoreNodes: ['dataValidations']; the preview
  never shows validations, and a whole-column dropdown took 5 s

The XML counter now scans up to the last complete tag and carries the
rest, so a merge or col tag cut by an inflate-chunk edge is read whole.
A central-directory compressedSize that runs past the file is refused,
since the ratio cap divides by it.

The renderer and core axis offsets use prefix sums with a binary search
instead of walking every override per call.
@aakhter

aakhter commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for running the worker core through the harness, those three expansions are a nasty class. All of it is addressed in bfab172.

  1. Admission now bounds what ExcelJS expands.

    • The worker loads with xlsx.load(admitted, { ignoreNodes: ['dataValidations'] }). A whole-column dropdown (B2:B1048576) went from 5.3 s to 57 ms here.
    • createXmlCounter adds each <mergeCell ref> area (rows x cols via parseRange, reversed corners included) to the per-sheet and total cell counts, and refuses a ref that does not parse.
    • A <col> whose min or max is outside 1-16384 is refused.
    • While doing this I found the counter's old 128-byte carry could split a tag at a chunk edge and drop it, which matters now that attributes are read. It now scans only up to the last complete tag and carries the rest (with a cap on how long a carried tag may get). A core test cuts each fixture at every byte offset, and it fails on the old carry.
    • Worker-harness fixtures for all three: the A1:CV30000 merge and the max="3000000" col are refused before ExcelJS loads, and the dropdown sheet previews, with an assertion that the loaded worksheet has no validations model. Removing ignoreNodes fails that test in about 5 s instead of hanging the suite.
  2. Axis math. The core's createSparseAxis keeps a prefix-sum array next to the sorted overrides, and the renderer keeps one per override list (built on first use for a sheet, cached in a WeakMap). Both binary-search, so each axisOffset is O(log n). A test compares the core against a plain walk at every index.

  3. Declared compressedSize. inspectZipDirectory refuses an entry whose declared compressed size runs past the start of the central directory, so the ratio cap divides by a real number. Pinned with your 0x7fffffff case.

docs/architecture-invariants.md and the CLAUDE.md line now mention the expansion bounds, and SPREADSHEET_ASSET_VERSION is updated. Typecheck, lint, format, the asset checks, the spreadsheet tests and the Chromium test pass here.

Thanks for picking up the hash check and the openFilePreview race separately.

@Ark0N

Ark0N commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Thanks again, @aakhter. This PR adds a read-only XLSX preview to the file-preview overlay, parsed in a browser worker behind admission caps. I checked the round 2 items at bfab172: ignoreNodes: ['dataValidations'], the prefix-sum axis, the declared compressedSize check and the chunk-edge carry are all in. Typecheck, lint, format, the asset checks, the full npm test gate and your Chromium test pass here.

The new attribute reads can still be fooled, so the expansion bounds from round 2 do not hold yet. Two things need fixing before merge:

  1. Quoted text defeats the <mergeCell> and <col> checks (src/web/public/spreadsheet-xlsx-core.js:128, with the tag regexes at lines 181 and 188). attribute() takes the first \sref="..." anywhere in the tag text, and the tag regexes end at the first >. But XML allows a raw >, and the other quote character, inside an attribute value, and saxes reads the whole tag correctly. I ran your core and ExcelJS 4.4.0 through the worker-test path, and both of these 6.5 KB files were admitted:

    • <mergeCell x=' ref="A1"' ref="A1:CV30000"/> was counted as 2 cells. ExcelJS built 3,000,000 cells (726 MB retained, 2.9 s).
    • <col x=">" min="1" max="3000000"/> (and <col x=' max="1"' min="1" max="3000000"/>) passed the 1-16384 check. ExcelJS built 3,000,000 columns (537 MB retained).

    Please:

    • Read attributes in order from the tag name with a sticky regex that consumes each quoted value whole, for example \s*([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)').
    • Refuse any <mergeCell or <col whose attributes do not parse up to /> or >.
    • Since < can never appear inside an attribute value, make the chunk carry keep everything from the last < whenever the chunk is not final. That replaces the lastIndexOf('>') test, which a quoted > fools the same way.
    • Add all three fixtures to test/spreadsheet-preview-worker.test.ts.
  2. Empty <row> tags are not counted (src/web/public/spreadsheet-xlsx-core.js:179-189). ExcelJS keeps a Row object for every <row> element, with or without cells. A 6.2 MB file with three sheets of 1,048,576 <row r="N"/> (50.8 MB inflated, inside every cap) was admitted as 0 cells, then took 6.9 s and 522 MB retained to load. For comparison, a real 249,000-cell workbook right at your caps loads in 1.0 s with 75 MB. Please:

    • Count <row tags per sheet and in total, with caps shaped like the cell ones (for example 100k per sheet and 250k total).
    • Refuse with its own error code, and add a worker-harness fixture.

    ExcelJS's maxRows load option (xlsx.load(admitted, { ignoreNodes, maxRows })) works as a per-sheet backstop but does not replace the total.

Two smaller ones that fit in the same round:

  • src/web/public/spreadsheet-xlsx-core.js:191-196: the inCellXfs state flips on a </cellXfs> inside an XML comment, so <cellXfs><!-- </cellXfs> --> followed by 10,000 <xf/> counts 0 styles. The worker's normalized-style cap still holds. Counting every <xf tag in styles.xml without state removes the desync.
  • src/web/public/spreadsheet-preview-worker.js:91: eachRow({ includeEmpty: false }) only visits rows that have values, so an empty spacer row with a custom height, or a hidden empty row, renders at the default height. A follow-up is fine for this one.

The SPREADSHEET_ASSET_VERSION test move and the openFilePreview race stay with me, as planned. Once the two admission fixes land, this is ready to merge. Thanks for sticking with it through three rounds. The worker design is solid, and these are the remaining gaps in the counter in front of ExcelJS.

…view

# Conflicts:
#	src/web/public/constants.js
… rows

XML allows a raw `>` and the other quote character inside an attribute
value, so a first-match search for `ref=`/`max=` could be fed a fake
value from an earlier attribute while saxes read the real one:

- <mergeCell>/<col> attributes are now read in order from the tag name
  with a sticky regex that consumes each quoted value whole. A tag whose
  attributes do not parse up to `>`, or that repeats a name, is refused.
- The chunk carry keeps everything from the last `<`, which can never
  appear inside an attribute value, instead of comparing against the
  last `>`.

ExcelJS keeps a Row object for every <row>, cells or not, so <row> tags
now count against per-sheet (100k) and total (250k) caps with their own
row-limit code, and the worker passes maxRows as a per-sheet backstop.

styles.xml counts every <xf> without tracking which list it sits in,
since a </cellXfs> inside a comment desynced that state.
@aakhter

aakhter commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the third round. The quoted-value trick is a good catch, and one the second-round fix did not anticipate. All of it is in d65ee4f, on top of a merge of current master (1.33.3; the only conflict was the extension lists in constants.js, now carrying both avif/ico and xlsx).

  1. Attributes are read in order. readTagAttributes() walks from the end of the tag name with a sticky [ \t\r\n]+([^ \t\r\n=/>]+)[ \t\r\n]*=[ \t\r\n]*(?:"([^"]*)"|'([^']*)'), so each quoted value is consumed whole. A <mergeCell> or <col> is refused when its attributes do not parse up to /> or >, and when a name repeats (XML forbids that, and it would be the next way to disagree with saxes).

    • The chunk carry now keeps everything from the last < whenever the chunk is not final, as you suggested.
    • All three of your fixtures are in test/spreadsheet-preview-worker.test.ts and are refused before ExcelJS loads. The chunk-edge test also cuts a <col x=">" ...> at every byte offset.
  2. Rows are counted. Every <row tag counts per sheet and in total (100k / 250k) and refuses with row-limit. The worker also passes maxRows: maxRowsPerSheet as the per-sheet backstop. The worker fixture is a sheet with 100,001 empty rows.

Smaller ones:

  • styles.xml now counts every <xf> with no list-tracking state, so the comment desync is gone (this also counts cellStyleXfs, which only makes the cap stricter).
  • I've left the empty-spacer-row height (eachRow({ includeEmpty: false })) as the follow-up you offered. The clean fix needs ExcelJS's sparse row list rather than includeEmpty: true, which would materialize every row.

I broke each fix on purpose and the tests catch it: a non-sticky attribute search, uncounted rows, uncounted styles and the old >-based carry each fail at least one test. Typecheck, lint, format, the asset checks (SPREADSHEET_ASSET_VERSION updated), the spreadsheet and file-route tests and the Chromium test all pass.

@Ark0N

Ark0N commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Thanks again, @aakhter. This PR adds a read-only XLSX preview to the file-preview overlay, parsed in a browser worker behind admission caps.

I checked the round 3 items at d65ee4f and they all hold:

  • attributes are read in order
  • the carry keeps everything from the last <
  • <row> tags are capped
  • every <xf> is counted

Typecheck, lint, format, the asset checks, the full npm test gate (444 files) and your Chromium test all pass here.

Running your worker and core through the worker-test harness again turned up three more problems. Two are in the admission layer, the same class as rounds 2 and 3, and one is a dependency pin.

  1. Sheet entry names are checked as stored in the ZIP, but ExcelJS sees a different name (src/web/public/spreadsheet-xlsx-core.js:186, also :187, :258 and :287).

    On load, JSZip inside ExcelJS drops ., .. and empty path segments from every entry name. ExcelJS then strips one leading / and matches worksheets with an unanchored xl/worksheets/sheet<N>.xml pattern.

    A 6.3 KB file whose sheet carries <mergeCell ref="A1:CV30000"/> is refused with cell-limit when the entry is named xl/worksheets/sheet1.xml. It is admitted and loaded as 3,000,000 cells (841 MB, about 12 s) under any of these names:

    • /xl/worksheets/sheet1.xml
    • xl/./worksheets/sheet1.xml
    • xl//worksheets/sheet1.xml
    • xl/xl/worksheets/sheet1.xml (rel Target xl/worksheets/sheet1.xml)
    • xl/worksheets/sheet1.xml.x (rel Target worksheets/sheet1.xml.x)

    The counter never runs on those entries, so every cap from rounds 2 and 3 is skipped. Please:

    • Compute the name ExcelJS will see for each entry: JSZip's segment resolution, then strip one leading /.
    • Refuse two entries that land on the same name.
    • Key inflatedEntries (and so the rebuilt archive) on that name.
    • Use that name for createXmlCounter and the worksheet cap, and count any name matching the unanchored xl/worksheets/sheet<N>.xml pattern as a worksheet. This also fixes /xl/styles.xml, which skips the style counter today.
    • Add a worker fixture for each of the five names. A merge of A1:A100001 (one cell over the per-sheet cap) makes a regression fail in under a second instead of building 3M cells.
  2. Defined names expand into one object per cell (src/web/public/spreadsheet-preview-worker.js:127).

    ExcelJS's DefinedNames model setter walks every cell of every $A$1:$B$2-style range in xl/workbook.xml, and admission never scans that file. Measured through the worker:

    • a named range from row 2 to the bottom of one column: 291 MB
    • $B$2:$F$1048576: 619 MB
    • _xlnm._FilterDatabase over $A$1:$F$1048576: 691 MB
    • $A$1:$XFD$1048576: ran out of a 4 GB heap after 18 s

    The preview never shows defined names, so the ignoreNodes approach works here too. Right after new self.ExcelJS.Workbook(), add:

    Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });

    ExcelJS assigns defined names in one place (lib/doc/workbook.js:214). Print areas and titles are handled before that, without expansion. If a future version renames _definedNames, defineProperty throws instead of quietly expanding again. With this line the whole-sheet name loads in 52 ms.

    Please add a worker test with a small range (Data!$A$1:$J$10) that asserts workbook.definedNames.matrixMap stays empty, so a regression fails cleanly.

  3. fflate@0.8.2 has advisory GHSA-px8p-9vwx-vf98 (package.json:132), an infinite loop on a ZIP64 marker that is fixed in 0.8.3. The worker's streaming Unzip reaches it.

    Setting the first local header's compressed size to 0xffffffff in a 6.3 KB workbook spins admission until the 20 s timeout. inspectZipDirectory only checks the central directory, and fflate parses a local header before any admission callback runs.

    0.8.3 bounds that loop, ships the same umd/index.js that sets self.fflate, and rejects that file in 4 ms. Please:

    • pin "fflate": "0.8.3" and regenerate the lockfile
    • refresh SPREADSHEET_ASSET_VERSION
    • add expectEveryLockedVersionAtLeast(lock, 'fflate', '0.8.3') to test/dependency-security.test.ts

    Skip a fixture test for this one: on 0.8.2 the loop is synchronous, so it hangs the run instead of failing it.

I prototyped fixes 1 and 2 on your branch:

  • the five name variants and the defined-name test fail on the current head and pass with the fixes
  • all existing spreadsheet tests still pass (72/72)
  • the at-caps baseline is unchanged (247k cells, 75 MB, about 2 s)

Please also extend the CLAUDE.md line and the architecture-invariants paragraph with the two new mechanisms, the way they already name ignoreNodes.

One small docs item for the same round: the preview table in docs/wiki/Working-With-Files.md:20 still says Office documents need a converter. A row for .xlsx (read-only, parsed in the browser, 10 MB limit, .xls/.ods download only) covers it.

The SPREADSHEET_ASSET_VERSION test move, the openFilePreview race and the spacer-row heights stay with me, as planned. Once these three land, this is ready to merge. Thanks for sticking with it. The worker design keeps holding up, and these are gaps in what sits in front of ExcelJS.

…efined names, pin fflate 0.8.3

Admission checked ZIP entry names as stored, but JSZip (inside ExcelJS)
resolves `.`, `..` and empty segments on load, and ExcelJS strips one
leading `/` and matches worksheets with an unanchored pattern. Names like
`/xl/worksheets/sheet1.xml` or `xl/worksheets/sheet1.xml.x` skipped every
counter. Admission now computes the name ExcelJS will see for each entry,
refuses two entries that resolve to the same name, keys the rebuilt
archive on it, and picks the worksheet/styles counters from it.

ExcelJS's DefinedNames model setter expands every range into one object
per cell. The preview never shows defined names, so the worker stubs
`_definedNames.model` before load.

Pin fflate to 0.8.3 (GHSA-px8p-9vwx-vf98) and refresh
SPREADSHEET_ASSET_VERSION.
@aakhter

aakhter commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Ark0N, and thanks for prototyping these. All three are in 2d0ffb7.

  1. Entry names. Admission now works out the name ExcelJS will see for each entry before doing anything with it: JSZip's segment resolution (. and empty segments dropped, .. pops a segment, as in utils.resolve), then one leading / stripped (as in xlsx.js load).
    • Two entries that land on the same name are refused.
    • inflatedEntries, and so the rebuilt archive, is keyed on that name, and it drives createXmlCounter and the worksheet cap.
    • Any name matching ExcelJS's unanchored xl/worksheets/sheet(\d+)[.]xml counts as a worksheet, and /xl/styles.xml now gets the style counter.
    • There is a worker fixture for each of your five names, with a merge of A1:A100001. Each one first loads the file with plain ExcelJS to show it really parses that merge, then asserts the worker refuses it with cell-limit before ExcelJS loads.
    • Core tests cover the name mapping, the same-name refusal, and counting and rebuilding under the resolved name.
  2. Defined names. The worker stubs _definedNames.model right after new ExcelJS.Workbook(), exactly as you suggested. A worker test with Data!$A$1:$J$10 asserts workbook.definedNames.matrixMap stays empty.
  3. fflate. Pinned to 0.8.3 with the lockfile regenerated and SPREADSHEET_ASSET_VERSION refreshed. expectEveryLockedVersionAtLeast(lock, 'fflate', '0.8.3') is in test/dependency-security.test.ts, and the exact-pin assertion in test/spreadsheet-assets.test.ts moved to 0.8.3 as well.

The new tests fail on the previous head and pass now. Making the name mapping an identity fails five of them. CLAUDE.md and the architecture-invariants paragraph describe both new mechanisms, and docs/wiki/Working-With-Files.md has an .xlsx row. Typecheck, lint, format, the asset and lockfile checks, the spreadsheet and dependency tests and the Chromium test pass.

@Ark0N

Ark0N commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Thanks again, @aakhter. This PR adds a read-only XLSX preview to the file-preview overlay, parsed in a browser worker behind admission caps.

I checked the round 4 items at 2d0ffb7 and all three hold:

  • excelJsEntryName() matches JSZip's utils.resolve line for line, and a second pass through JSZip leaves the resolved name unchanged. Counters, the worksheet cap and the rebuilt archive all key on that name.
  • The _definedNames.model stub is in place, and _definedNames exists in the shipped dist/exceljs.min.js as well as in the Node build the tests use.
  • fflate is pinned to 0.8.3, the lockfile integrity matches the registry tarball, and SPREADSHEET_ASSET_VERSION recomputes to 2bb0eff45e39 from the 0.8.3 bundle.

Typecheck, lint, format, the asset and lockfile checks, the full npm test gate (444 files) and your Chromium test all pass here, and CI is green.

I ran the worker and core through the worker-test harness again and found three more problems. Two are gaps in front of ExcelJS, and both are about the index a row or sheet claims rather than how many there are. The third is a cost on every tile.

  1. A large <row r> makes every row walk cost seconds (src/web/public/spreadsheet-xlsx-core.js:246, walked at src/web/public/spreadsheet-preview-worker.js:81, :88, :91 and :214).

    ExcelJS stores each row at _rows[r - 1] (Worksheet._parseRows, with no bound on r). Both eachRow and the sheet.model getter walk _rows with forEach, which visits every index up to the largest r.

    A 6.5 KB file with five normal rows and one <row r="50000000"> is admitted as 6 cells. It takes 5.3 s to load, and then each tile request costs 1.7 s of worker CPU. Tiles have no timeout, and the renderer sends one per animation frame while scrolling, so the worker falls further behind until the overlay closes. Please:

    • read <row> attributes with readTagAttributes(), as for <mergeCell> and <col>
    • refuse an r that is not plain digits in 1 to 1,048,576 (an absent r is fine)
    • add a worker fixture
  2. A large sheetId in xl/workbook.xml allocates about 0.5 GB (src/web/public/spreadsheet-preview-worker.js:149).

    ExcelJS stores each sheet at _worksheets[sheetId] (lib/doc/workbook.js:204), and the worksheets getter the worker iterates runs _worksheets.slice(1).sort(...). Admission never scans xl/workbook.xml. Measured through the worker on a 6.5 KB one-cell workbook:

    • sheetId="1": 42 ms, 90 MB RSS
    • sheetId="30000000": 1.6 s, 557 MB RSS
    • sheetId="100000000": 15.3 s
    • anything above about 1.3e8 runs into the 20 s timeout

    Please:

    • add a counter for the resolved xl/workbook.xml that reads <sheet> attributes in order
    • refuse a sheetId that is not plain digits or is above a small cap (sheetId="1000000" still loads in about 50 ms)
    • add a worker fixture
  3. sendTile re-serializes the whole sheet on every tile (src/web/public/spreadsheet-preview-worker.js:214).

    sheet.model builds every row and cell model just to read the merges that worksheetMetadata already has. That costs 10 to 19 ms per tile on a 100k-cell sheet here, once per animation frame while scrolling. Please keep each sheet's merges in a map next to populatedRowsById and reuse them.

One small one for the same round: in src/web/public/spreadsheet-xlsx-core.js:551, a format of 0. followed by more than 100 zeros makes toLocaleString throw a RangeError, and the whole grid is replaced with the error. Excel caps decimals at 30, so Math.min(decimals, 30) covers it.

Please also:

  • extend the CLAUDE.md line and the architecture-invariants paragraph with the two new bounds
  • update the PR description, which still says fflate@0.8.2

The SPREADSHEET_ASSET_VERSION test move, the openFilePreview race and the spacer-row heights stay with me, as planned. Once these land, this is ready to merge. Thanks for sticking with it through five rounds. The worker design keeps holding up, and these are the last index-shaped gaps I found in front of ExcelJS.

…s per tile, cap format decimals

ExcelJS stores a row at _rows[r - 1] and a sheet at _worksheets[sheetId], and
walks or slices those arrays up to the largest index, so the index a row or
sheet claims is a cost of its own. Admission now reads each <row> tag's
attributes in order and refuses an r outside 1-1048576 (absent r is fine), and
a counter for the resolved xl/workbook.xml reads every <sheet> tag and refuses
one that does not parse or whose sheetId is not plain digits up to
LIMITS.maxSheetId (65535).

sendTile no longer reads sheet.model, which rebuilt every row and cell model on
each tile: the merges read in worksheetMetadata are kept in mergesById next to
populatedRowsById, replaced on load and cleared on dispose.

Number formats cap decimals at 30, as Excel does; toLocaleString throws a
RangeError above 100 and the whole grid was replaced by the error.

Docs: CLAUDE.md and architecture-invariants describe both bounds and the merge
reuse. SPREADSHEET_ASSET_VERSION is recomputed for the edited worker and core.
@aakhter

aakhter commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks again for another careful round. All four are in c1811fd.

  1. <row r>. The worksheet counter now reads every <row> tag's attributes with readTagAttributes(), the same way as <mergeCell> and <col>.

    • It refuses a tag whose attributes don't parse, and any r that isn't plain digits in 1 to 1,048,576. An absent r is still fine.
    • The new worker fixture is five normal rows plus <row r="50000000">. Before the fix it was admitted and ExcelJS loaded it; now it's refused before ExcelJS is imported.
    • Core tests also cover 0, 1048577, 1a, -1, 1e3, a leading space, an empty value, a quoted fake r, and the boundary 1048576.
  2. sheetId. A new counter for the resolved xl/workbook.xml, keyed on the same normalized name as the worksheet and styles counters, reads every <sheet> tag in order.

    • It refuses a tag that doesn't parse, or whose sheetId isn't plain digits up to the new LIMITS.maxSheetId of 65535. A (?=[\s/>]) lookahead keeps <sheets> from matching.
    • Excel never reuses sheet ids, so real ones stay small, and 65535 is far below the 1,000,000 you measured at about 50 ms. An absent sheetId is allowed, since ExcelJS turns it into NaN, which becomes a plain property rather than an array slot.
    • The worker fixture is a one-cell workbook with sheetId="30000000". It was admitted before and is now refused before ExcelJS loads.
    • Core tests cover a non-digit value, a bad second sheet, a /xl/./workbook.xml spelling, a quoted fake sheetId, an unparseable tag, and 65535 being accepted.
  3. sendTile no longer touches sheet.model. The merges worksheetMetadata already reads are kept in mergesById next to populatedRowsById, replaced on load and cleared on dispose. The worker test loads a workbook, uses peek to make that sheet's model getter throw, then asks for a tile and checks that it comes back with the A4:C4 merge and its anchor text.

  4. Number formats cap decimals at 30 with Math.min(..., 30). A core test formats 0. followed by 120 zeros instead of throwing.

The CLAUDE.md line and the architecture-invariants paragraph now cover both bounds and the merge reuse, and SPREADSHEET_ASSET_VERSION is recomputed. The PR description now says fflate 0.8.3.

Typecheck, lint, format, the asset, lockfile and browser-exclude checks, the spreadsheet and dependency tests and the Chromium test all pass. I also checked that each new test fails when its fix is reverted.

@Ark0N

Ark0N commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Thanks again, @aakhter. This PR adds a read-only XLSX preview to the file-preview overlay, parsed in a browser worker behind admission caps.

I checked the round 5 items at c1811fd and all four hold:

  • <row r> is read in order and bounded to 1-1,048,576
  • sheetId in xl/workbook.xml is capped at 65535
  • sendTile reads merges from mergesById and never touches sheet.model
  • number formats cap decimals at 30

Typecheck, lint, format, the frontend-syntax, browser-exclude, lockfile and asset checks, the full npm test gate and your Chromium test pass here, and CI is green.

One more thing needs fixing before merge. It is the column-axis twin of the round 5 row index.

  1. A cell's column index makes every row walk cost up to 16,384 steps (src/web/public/spreadsheet-preview-worker.js:84, :86, :91 and :94 in worksheetMetadata, and :216 in sendTile).

    ExcelJS keeps a row's cells at row._cells[col - 1]. When a row's only cell sits in a far column such as XFD, V8 stores that array in dictionary mode. ExcelJS's row.eachCell (_cells.forEach) and row.hasValues (which sheet.eachRow calls) then visit every index up to 16,384, about 0.5 ms per row. The worker walks each row four times at load and once per tile. Admission cannot refuse this by range, because XFD is a legal column.

    Measured in headless Chromium with the shipped dist/exceljs.min.js and fflate 0.8.3:

    • A 47 KB file with 6,000 rows, each holding one XFD cell with ht="0.01", took 8.1 s to load and then 1.9 s per tile. The tiny rows make the renderer request rows 1 to 6,000 in every tile. A short horizontal scroll posted 12 tile requests, about 23 s of queued worker time, and tiles have no timeout.
    • A 68 KB file with 10,000 rows took 13.1 s to load. About 15,000 rows reaches the 20 s timeout.

    Merge slave cells in a far column cost the same way. Please:

    • Build the per-sheet row and cell index in worksheetMetadata from the keys that exist (Object.keys(sheet._rows) and Object.keys(row._cells)), skipping falsy and Null-type cells the way eachCell({ includeEmpty: false }) does. Read row heights and merges from that same pass instead of a second eachRow and sheet.model. On the 10,000-row file this walk takes 4 ms, against 9.8 s for eachRow + eachCell.
    • In sendTile, read each populated row's cells from that index instead of row.eachCell.
    • Add a worker test in the style of your sheet.model one: make eachRow and eachCell throw via peek, and assert that load and a tile still succeed. Add a fixture of a few thousand rows that each hold one XFD cell.
    • Extend the CLAUDE.md line and the architecture-invariants paragraph the way they already describe the row index.

One small one for the same round:

  • src/web/public/spreadsheet-xlsx-core.js:750 and :754: the clrScheme and slot regexes in parseThemePalette are quadratic when an opening tag has no matching close, because each candidate scans to the end of the text. A theme1.xml of repeated <a:clrScheme> costs 1.6 s at 260 KB and 6.3 s at 520 KB, and a 1 MB stored theme took 21.8 s in Chromium, so the preview times out. Real theme files are under 10 KB, so returning the default palette above 64 KB closes it. Please add a core test with a 1 MB theme that expects the default palette.

For a follow-up, no need to hold this PR for it: ExcelJS creates every column object from 1 up to the highest column a sheet touches, so 50 sheets with <col min="1" max="16384"/> (a 37 KB file) build 819,200 of them, about 157 MB of heap. That is bounded, and a workbook budget on each sheet's highest column would close it.

The SPREADSHEET_ASSET_VERSION test move, the openFilePreview race and the spacer-row heights stay with me, as planned. Once the column-index fix and the theme cap land, this is ready to merge. Thanks for staying with it through six rounds. The rebuilt-archive design keeps holding up, and this is the last index-shaped gap I found.

ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only
cell sits in XFD is a dictionary-mode array that `eachCell` and
`hasValues` (behind `eachRow`) walk to index 16,384. The worker walked
each row four times at load and once per tile, so a small file of
far-column rows took seconds to load and to tile.

`worksheetMetadata` now builds each sheet's row and cell index from
`Object.keys(sheet._rows)` and `Object.keys(row._cells)`, sorted
numerically, skipping falsy and Null-type cells exactly as
`eachCell({ includeEmpty: false })` does and keeping a row only when it
holds one such cell (`hasValues`). Styles, extent and row heights come
from that one pass and merges from `sheet._merges`; `sendTile` reads
each row's cells from the index.

`parseThemePalette` returns the default palette for a theme above
64 * 1024 characters, since its patterns are quadratic on unclosed tags.
@aakhter

aakhter commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for round 6, and for measuring the far-column cost in Chromium. Both items are in 7d3e27f.

  1. Column-index walk.

    • Index from present keys. worksheetMetadata now builds each sheet's row and cell index from Object.keys(sheet._rows) and then Object.keys(row._cells) per row, sorted numerically (presentIndices() / populatedRowIndex()).
    • Same emptiness rules as ExcelJS 4.4.0. A cell counts when it is truthy and its type is not ValueType.Null (row.js:141-145). A row counts when it holds at least one such cell, which is what hasValues checks (row.js:212, called from worksheet.js:563).
    • One pass at load. Styles, the extent and row heights and hidden flags all come from that pass; the second eachRow is gone. Merges come from sheet._merges, the map the model getter itself lists, so sheet.model never runs. Far-column merge slave cells sit in the same key-based lists, so nothing walks a dense _cells.
    • Tiles. populatedRowsById keeps each populated row with its cell list, and sendTile reads cells from it instead of row.eachCell. The merge-anchor path only does getCell/getRow index lookups, which never walk _cells.
    • Throwing-walk test. In the style of the sheet.model one: ExcelJS's eachRow, eachCell, hasValues and the model getter are made to throw before load and stay throwing through a tile. The test asserts that both succeed, with the exact cells and the A4:C4 merge. On the previous head it fails with eachRow touched.
    • XFD fixture. 3,000 rows that each hold one XFD cell with ht="0.01", loaded and tiled over rows 1 to 3,001 at the far columns. It runs in about 60 ms, against 3.6 s on the previous head in Node.
    • Emptiness parity test. Styled empty cells, and rows holding only them, are skipped exactly as ExcelJS skips them. This one also passes on the previous head, which shows the behaviour did not change.
    • Docs. The key-based walk is in both the CLAUDE.md line and the architecture-invariants paragraph, next to the row index.
  2. Theme cap. parseThemePalette returns the default palette when the theme XML is longer than 64 * 1024 characters. A core test feeds it 1 MB of repeated <a:clrScheme> and expects the default palette in under a second (9.2 s without the cap). It also checks the boundary: a real theme padded to exactly 64 KB still parses, and one character more falls back.

I checked each new test against a mutation. Putting eachRow/eachCell back in either place, reading merges from sheet.model, dropping the Null skip, or dropping the theme cap each fails a test. Typecheck, lint, format, frontend-syntax, public-assets (with the new SPREADSHEET_ASSET_VERSION), browser-excludes, lockfile, the spreadsheet test files and the Chromium preview test all pass.

I've noted the column-object budget for a separate change, as you suggested, and left it out of this PR. Thanks again for sticking with this through six rounds.

@Ark0N

Ark0N commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Thanks again, @aakhter. This PR adds a read-only XLSX preview to the file-preview overlay, parsed in a browser worker behind admission caps.

I checked the round 6 items at 7d3e27f and both hold:

  • worksheetMetadata and sendTile walk only the keys that exist. 10,000 rows of one XFD cell with ht="0.01" now load in 348 ms in the worker harness, and a tile takes 15 ms.
  • parseThemePalette falls back to the default palette above 64 KB.

Typecheck, lint, format, the frontend-syntax, browser-exclude, lockfile and asset checks, the full npm test gate and your Chromium test pass here, and CI is green.

This round I measured what still gets through to the page and to ExcelJS. Three things need fixing before merge. The first matters most, because it freezes the page itself rather than the worker.

  1. Cell text has no length bound on its way to the page (src/web/public/spreadsheet-preview-worker.js:159, from formatCellValue at src/web/public/spreadsheet-xlsx-core.js:562, written at src/web/public/spreadsheet-preview.js:229).

    Every cell in a tile carries its whole string, and structured clone copies it once per cell. The 20 s timeout does not cover this, since it is cleared when metadata arrives (spreadsheet-preview.js:430). Measured in headless Chromium with the shipped bundles:

    • a 795 KB workbook with one 1 MB shared string referenced by a 60 x 20 block left the page's main thread unresponsive for the full 150 s I watched, while Chromium grew to 9.6 GB, and no cell rendered
    • Excel-legal 32,767-character cells (a 33 KB file, 800 visible cells) froze the page for about 4 s at 1 GB
    • the same block with 1 KB strings renders in about 2 s at 516 MB

    Please:

    • cap each cell's display text in the worker, covering rich text and hyperlink text (1,000 characters is plenty: a cell is one nowrap line with an ellipsis, so nothing past the column width shows)
    • add a worker test with a long shared string that asserts every tile cell's text is within the cap
  2. Merges have no workbook-wide cap, and ExcelJS loads them quadratically per sheet (src/web/public/spreadsheet-xlsx-core.js:287-291, LIMITS at :20).

    Worksheet._mergeCellsInternal checks each new merge against every earlier one on the sheet (_.each(this._merges), which rebuilds Object.keys on every call), so a sheet's cost grows with the square of its merge count. counts.merges is counted but never compared with a limit. Measured:

    • worker harness: one sheet at your 5,000 cap takes 1.6 s, two take 3.1 s
    • Chromium: 10 sheets of 5,000 one-cell merges (220 KB) took about 14 s to load, and 20 sheets (433 KB) hit the 20 s timeout

    Please add a workbook-wide merge cap and size the per-sheet cap against that cost. For example, 2,000 per sheet and 10,000 in total keeps the worst case near 1.3 s here. Refuse with merge-limit before ExcelJS loads, and add a worker fixture.

  3. A number format with an unclosed [ makes ExcelJS's date check quadratic (the styles counter at src/web/public/spreadsheet-xlsx-core.js:302-307).

    ExcelJS runs utils.isDateFmt once per numeric cell at load. Its first step, fmt.replace(/\[[^\]]*]/g, ''), rescans to the end of the code for every [ that has no later ]. Measured:

    • a 60,000-character code of [ costs 2.2 s per numeric cell, so a 76 KB workbook with ten such cells hit the 20 s timeout in Chromium
    • the same code closed by one ] costs 0.17 ms
    • a 255-character unclosed code still costs about 0.045 ms per numeric cell, which adds up to seconds at the cell cap, so a length cap alone does not close it

    The whole code is also echoed into the notice bar through Unsupported number format: ${code} (:545 and :606). Please read <numFmt> attributes with readTagAttributes(), refuse a formatCode longer than 255 characters or with a [ after its last ], and add a worker fixture.

Please also extend the CLAUDE.md line and the architecture-invariants paragraph with the text cap and the two new bounds, and refresh SPREADSHEET_ASSET_VERSION.

The openFilePreview race and the spacer-row heights stay with me, as planned. Once these three land, this is ready to merge. Thanks for staying with it through seven rounds. The rebuilt-archive design keeps holding up, and these are gaps in what the admission layer and the renderer let through.

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.

2 participants