Skip to content

render: single-value formatters as a headless @malloydata/render/format entry - #3140

Merged
lloydtabb merged 3 commits into
mainfrom
render-format-entry
Oct 13, 2026
Merged

lloydtabb merged 3 commits into
mainfrom
render-format-entry

Conversation

@lloydtabb

@lloydtabb lloydtabb commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Why

A value's display format (# currency, # percent, # duration, # number) could only be applied by mounting the renderer:

  • the formatters take a data-tree Field, and
  • the package's one entry loads Solid, Vega and the DOM.

Anything else that wants a value to read the way a rendered table shows it has had to reimplement the formats and drift from them. That includes a server writing "$521k" into a summary, a dashboard drawing its own KPI card, and an LLM reporting a number. Concretely, Malloyyo now hands each result column's declared format to its MCP clients (malloydata/malloyyo#204) and wants a format(path, value) for custom dashboards that matches the renderer exactly.

What

src/format/: the formatters, with no Field.

  • resolveCellFormat(tag, {isTime}) → CellFormatConfig. This is the same plain-data config resolveCellFormatTags already produced, and it survives JSON.stringify.
  • formatNumber(value, config) handles numbers, plus digit strings for exact bigints.
  • formatDateTime(value, config, {timeframe, isDate, timezone}).
  • formatDuration, formatScaledNumber, renderTimeString, NULL_SYMBOL.

The renderer uses the same code. resolveCellFormatTags and the render*Field helpers are now thin wrappers that add only the Field lookups (its resolved config and its query timezone). Rendered output is unchanged. util.ts re-exports everything that moved, so existing imports keep working.

A new export, @malloydata/render/format. It's built separately by vite.config.format.mts into dist/format/index.{mjs,cjs}, with types from the main build's dist/module/format/.

  • Self-contained like the main build (external: []). Leaving dependencies external breaks Node ESM: ssf is CommonJS-only, and lodash/startCase is an extensionless subpath.
  • About 113 KB as CJS (36 KB gzipped), against 3.5 MB for the UMD renderer.
  • Loads in plain Node with no DOM stub, under both import and require. Checked through the package exports.

CONTEXT.md documents the second entry and the rule that keeps it headless: no data_tree, Solid, Vega or DOM imports in src/format/.

Tests

  • New src/format/cell-format.spec.ts covers resolution (currency shorthand, percent, duration, # number as a date pattern only on time fields, default, JSON round-trip) and formatting (currency, percent, number shorthands and ssf patterns, duration, bigint strings, null, date patterns, timeframes). The malloy-render jest project has no DOM setup, so this also shows the module doesn't need one.
  • malloy-render: 228/228 passed (the repo's jest config pins TZ=America/Los_Angeles). malloy-render-test (DOM renderer): 76 passed, 28 skipped. Consumer canary passes. tsc -p tsconfig.type-check.json, eslint and prettier are clean, and the full monorepo npm run build succeeds.

Date patterns now read the query's zone, not the host's (behaviour fix)

Before this PR, # number="mmm yyyy" handed a Date to ssf, which reads the host's local fields. So 1 Sep 2026 00:00 UTC rendered "Aug 2026" in any browser west of UTC, and a server and a browser could disagree. formatDateTime now formats the value's wall clock as a spreadsheet serial date, which carries no zone:

  • a timestamp reads in the query's time zone (the renderer passes the field's effective query_timezone, as it already did for the default path), otherwise UTC;
  • a date reads as its UTC calendar day.

This is the one change to rendered output: date-pattern fields on hosts that aren't in the value's zone, where the output was wrong. The spec covers the UTC midnight-on-the-1st case, a query time zone and a date. Note that jest.config.ts pins TZ=America/Los_Angeles, so the repo's suite only ever runs west of UTC. I also ran the spec with that line removed under UTC, America/Los_Angeles, Asia/Tokyo and Asia/Kolkata, and it passes in all four.

Differential check, old vs new. Using a throwaway harness (not committed), I rendered 7,824 cells through the renderer's own pipeline (RenderFieldMetadata → getDataTree → renderCellValue) on both main and this branch, with the host in the same four zones:

  • 28 number/currency/percent/duration formats × 13 values, plus bigints;
  • 12 date patterns plus no pattern, × every date and timestamp timeframe;
  • 5 query time zones, plus a nest with its own timezone:.

The results:

  • Every number, bigint and duration cell is identical, in every zone.
  • Every cell that differs uses a date pattern.
  • The new output is the same in all four host zones. On main, 5,070 of the 7,824 cells changed with the host's zone.
  • The harness also found a crash that exists on main: # currency=gbp… and # currency=pound throw unrecognized character £ in ssf. It's unchanged by this PR and worth its own fix.

Review follow-ups (second commit)

  • typesVersions maps format to its .d.ts. Checked: moduleResolution: node10 fails with TS2307 without it and resolves with it; bundler and node16 resolve either way.
  • resolveCellFormat returns a fresh default config instead of one shared object stored on every untagged field.
  • The invalid-date-pattern warning names the field again.
  • The legacy renderer's duration text (html/duration.ts) reuses formatDuration, which now takes the per-part # number pattern the legacy code applied, so only one copy of the duration logic remains.
  • The unused util.getText wrapper is removed.
  • Not changed: the ES build of ./format is about 258 KB unminified, against 113 KB for CJS. Vite's library mode won't strip whitespace from ES output, even with build.minify.

🤖 Generated with Claude Code

lloydtabb and others added 2 commits October 12, 2026 11:47
…at entry

A value's display format (`# currency`, `# percent`, `# duration`,
`# number`) could only be applied by mounting the renderer: the
formatters took a data-tree Field, and the package's one entry loads
Solid, Vega and the DOM. Anything that wanted a value to read the way a
rendered table shows it (a server writing a summary, a dashboard
drawing its own KPI card) had to reimplement the formats and drift.

src/format/ now holds them with no Field in sight:

- resolveCellFormat(tag, {isTime}) -> CellFormatConfig, the plain-data
  config resolveCellFormatTags already produced
- formatNumber(value, config): numbers, and digit strings for exact
  bigints
- formatDateTime(value, config, {timeframe, isDate, timezone})
- formatDuration, formatScaledNumber, renderTimeString, NULL_SYMBOL

The renderer's cells call these same functions:
resolveCellFormatTags and the render*Field helpers are now thin
wrappers that add only the Field lookups (its config, its query
timezone), so rendered output is unchanged. util.ts re-exports what
moved.

`@malloydata/render/format` is built separately (vite.config.format.mts)
into dist/format/index.{mjs,cjs}: self-contained like the main build,
about 113 KB as CJS, and it loads in plain Node with no DOM stub, under
both ESM and require.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <1093458+lloydtabb@users.noreply.github.com>
formatDateTime handed a Date to ssf for a `# number="…"` date pattern,
and ssf reads a Date's local fields, so the output depended on where it
ran: Sep 1 00:00 UTC printed "Aug 2026" in any browser west of UTC, and
a server and a browser could disagree. It now formats the value's wall
clock as a spreadsheet serial date (which carries no zone): in the
query's timezone for a timestamp, UTC otherwise, and the UTC calendar
day for a date. The renderer passes the field's effective query zone,
as it already did for the default path. This changes rendered output
only for date patterns on hosts not in the value's zone, where it was
wrong.

Review fixes on the same PR:
- typesVersions maps `format` to its .d.ts, so TypeScript on
  `moduleResolution: node` resolves @malloydata/render/format
- resolveCellFormat returns a fresh default config, not one shared
  object stored on every untagged field
- an invalid date pattern's warning names the field again
- the legacy renderer's duration text reuses formatDuration (which
  takes the `# number` part pattern it applied), so one copy remains
- the unused util.getText wrapper is gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <1093458+lloydtabb@users.noreply.github.com>
The comment claimed the suite also runs under other zones; jest.config.ts
pins TZ=America/Los_Angeles, which is the west-of-UTC host the test
needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <1093458+lloydtabb@users.noreply.github.com>
@lloydtabb
lloydtabb merged commit bd2501e into main Oct 13, 2026
21 checks passed
@lloydtabb
lloydtabb deleted the render-format-entry branch October 13, 2026 16:15
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