Skip to content

runtime: cache and freeze tagged-template string arrays #2855

@andrewtdiz

Description

@andrewtdiz

Summary

Perry supports tagged-template calls and exposes strings.raw, but the current lowering/codegen materializes fresh cooked/raw arrays on every evaluation. Node/ECMAScript creates one template object per tagged-template call site and reuses it across calls; both the cooked array and its .raw array are frozen.

Libraries use this identity and immutability contract for memoizing parsed template literals, SQL/tag caches, CSS-in-JS caches, and defensive mutation assumptions.

Node behavior

On Node v25.9.0:

let first;
function tag(strings) {
  console.log("same:", first === undefined ? "first" : first === strings);
  first ||= strings;
  console.log("frozen:", Object.isFrozen(strings), Object.isFrozen(strings.raw));
  try { strings[0] = "x"; } catch {}
  try { strings.raw[0] = "x"; } catch {}
  console.log("values:", strings[0], strings.raw[0]);
}

function call(v) {
  tag`a\n${v}b`;
}

call(1);
call(2);

prints:

same: first
frozen: true true
values: a\n a\\n
same: true
frozen: true true
values: a\n a\\n

The second call reaches the same tagged-template call site, so the strings object identity is reused. Mutation attempts do not change either array.

Current Perry evidence

Current tagged-template support is per-call materialization:

  • crates/perry-hir/src/lower/lower_expr.rs lowers a general tagged template to a regular call whose first argument is Expr::TaggedTemplateStrings { cooked, raw }, followed by interpolation values.
  • crates/perry-codegen/src/expr/instance_misc1.rs lowers Expr::TaggedTemplateStrings by calling lower_array_literal for the cooked array and another lower_array_literal for the raw array each time the expression is evaluated, then registers the pair with js_tagged_template_register_raw.
  • crates/perry-runtime/src/array/header.rs::js_tagged_template_register_raw() only records a side-table mapping from the cooked array pointer to the raw array pointer and returns the cooked pointer. It does not cache by call site, reuse an existing template object, mark either array frozen/non-extensible, or make the .raw relationship immutable.

That means repeated calls through the same tagged-template expression get distinct arrays, and the template objects are ordinary mutable arrays rather than spec-frozen template objects.

Suggested test surface

Add a Node-suite test like:

let first: any;
function tag(strings: TemplateStringsArray) {
  console.log("same:", first === undefined ? "first" : first === strings);
  first ||= strings;
  console.log("frozen:", Object.isFrozen(strings), Object.isFrozen(strings.raw));
  try { (strings as any)[0] = "x"; } catch {}
  try { (strings.raw as any)[0] = "x"; } catch {}
  console.log("cooked:", strings[0]);
  console.log("raw:", strings.raw[0]);
}
function call(v: unknown) {
  tag`a\n${v}b`;
}
call(1);
call(2);

Expected properties:

  • second call prints same: true
  • Object.isFrozen(strings) and Object.isFrozen(strings.raw) are both true
  • mutation attempts do not change cooked/raw values

Scope / non-goals

This issue is scoped to tagged-template template-object identity and immutability. It does not track the separate String.raw(...) callable semantics already covered by #2789, and it does not require changing the interpolation-value argument lowering.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions