Import Tint changes from Dawn

Changes:
  - 07c73adc95a1177315563225ad57517af4376633 [tint] Remove unused includes from MSL validate.h by James Price <jrprice@google.com>
  - 700892daf642d290bee5885644389af86d1d3ff0 Add WGSL writer helper to create a WGSL program. by dan sinclair <dsinclair@chromium.org>
  - b94b9ca68ecc762078f1540e98e917f395ba1747 [tint][fuzz][ir] Include IR disassembly when SPIR-V val f... by Ben Clayton <bclayton@google.com>
  - fc36dcfaa23772a4771dfdeb4df8d7b5d378a6a8 [tint][ir] Fix UAF in validator by Ben Clayton <bclayton@google.com>
  - 0a60d528a4a693ba225af966043f01ae1fdfb997 [tint][ir] Stylize more validator diagnostics by Ben Clayton <bclayton@google.com>
  - a655053bb2a77aeb4a7ebecd5f697c2102c12d6a [tint] Use EXPECT_DEATH_IF_SUPPORTED() by Ben Clayton <bclayton@google.com>
  - 590110ebb1dae02b4fc98ed5ca08f9c3aeabb3f5 [tint][ir] Fix Std140 transform for arrays of matrices by Ben Clayton <bclayton@google.com>
  - f35bd1bae613c97b4d93c19dd23a7009cd10e5fb [spirv-reader] Add transform to handle shader IO by James Price <jrprice@google.com>
  - a5ded402136cc3e734bcf5692c3534f7aa2e5afd [ir] Add ReferencedModuleVars helper by James Price <jrprice@google.com>
  - 0bb6d4d0b5c854e83cf3846092eb3079afd72194 [tint][ast] Include WGSL dump in fuzzer ICE message by Ben Clayton <bclayton@google.com>
  - c25a748790c61d35b185fb2996875efaeb530662 [ir] Add Instruction::DetachResult() helper by James Price <jrprice@google.com>
GitOrigin-RevId: 07c73adc95a1177315563225ad57517af4376633
Change-Id: Ifa9a927222958aed0240d2b9a99024a3c8572750
Reviewed-on: https://dawn-review.googlesource.com/c/tint/+/187800
Kokoro: James Price <jrprice@google.com>
Commit-Queue: James Price <jrprice@google.com>
Reviewed-by: James Price <jrprice@google.com>
diff --git a/src/tint/cmd/common/helper.cc b/src/tint/cmd/common/helper.cc
index 49fcf79..9f83e9a 100644
--- a/src/tint/cmd/common/helper.cc
+++ b/src/tint/cmd/common/helper.cc
@@ -124,23 +124,19 @@
             exit(1);
         }
 
-        // Convert the IR module to a WGSL string.
+        // Convert the IR module to a Program.
         tint::wgsl::writer::ProgramOptions writer_options;
         writer_options.allow_non_uniform_derivatives =
             opts.spirv_reader_options.allow_non_uniform_derivatives;
         writer_options.allowed_features = opts.spirv_reader_options.allowed_features;
-        auto wgsl_result = tint::wgsl::writer::WgslFromIR(result.Get(), writer_options);
-        if (wgsl_result != Success) {
-            std::cerr << "Failed to convert IR to WGSL:\n\n"
-                      << wgsl_result.Failure().reason << "\n";
+        auto prog_result = tint::wgsl::writer::ProgramFromIR(result.Get(), writer_options);
+        if (prog_result != Success) {
+            std::cerr << "Failed to convert IR to Program:\n\n"
+                      << prog_result.Failure().reason << "\n";
             exit(1);
         }
 
-        // Parse the WGSL string to produce a WGSL AST.
-        tint::wgsl::reader::Options reader_options;
-        reader_options.allowed_features = tint::wgsl::AllowedFeatures::Everything();
-        auto file = std::make_unique<tint::Source::File>(opts.filename, wgsl_result->wgsl);
-        return tint::wgsl::reader::Parse(file.get(), reader_options);
+        return prog_result.Move();
 #else
         std::cerr << "Tint not built with the WGSL writer enabled" << std::endl;
         exit(1);
diff --git a/src/tint/cmd/remote_compile/BUILD.bazel b/src/tint/cmd/remote_compile/BUILD.bazel
index 823c8da..501d62a 100644
--- a/src/tint/cmd/remote_compile/BUILD.bazel
+++ b/src/tint/cmd/remote_compile/BUILD.bazel
@@ -42,11 +42,8 @@
     "main.cc",
   ],
   deps = [
-    "//src/tint/lang/wgsl/ast",
     "//src/tint/utils/macros",
     "//src/tint/utils/socket",
-    "//src/tint/utils/text",
-    "//src/tint/utils/traits",
     
   ] + select({
     ":tint_build_msl_writer": [
diff --git a/src/tint/cmd/remote_compile/BUILD.cmake b/src/tint/cmd/remote_compile/BUILD.cmake
index a54b9fb..6841744 100644
--- a/src/tint/cmd/remote_compile/BUILD.cmake
+++ b/src/tint/cmd/remote_compile/BUILD.cmake
@@ -43,11 +43,8 @@
 )
 
 tint_target_add_dependencies(tint_cmd_remote_compile_cmd cmd
-  tint_lang_wgsl_ast
   tint_utils_macros
   tint_utils_socket
-  tint_utils_text
-  tint_utils_traits
 )
 
 tint_target_add_external_dependencies(tint_cmd_remote_compile_cmd cmd
diff --git a/src/tint/cmd/remote_compile/BUILD.gn b/src/tint/cmd/remote_compile/BUILD.gn
index d2b7baf..6653c2a 100644
--- a/src/tint/cmd/remote_compile/BUILD.gn
+++ b/src/tint/cmd/remote_compile/BUILD.gn
@@ -43,11 +43,8 @@
   sources = [ "main.cc" ]
   deps = [
     "${tint_src_dir}:thread",
-    "${tint_src_dir}/lang/wgsl/ast",
     "${tint_src_dir}/utils/macros",
     "${tint_src_dir}/utils/socket",
-    "${tint_src_dir}/utils/text",
-    "${tint_src_dir}/utils/traits",
   ]
 
   if (tint_build_msl_writer) {
diff --git a/src/tint/cmd/test/BUILD.bazel b/src/tint/cmd/test/BUILD.bazel
index c99263c..b6d308b 100644
--- a/src/tint/cmd/test/BUILD.bazel
+++ b/src/tint/cmd/test/BUILD.bazel
@@ -48,6 +48,7 @@
     "//src/tint/cmd/common:test",
     "//src/tint/lang/core/constant:test",
     "//src/tint/lang/core/intrinsic:test",
+    "//src/tint/lang/core/ir/transform/common:test",
     "//src/tint/lang/core/ir/transform:test",
     "//src/tint/lang/core/ir:test",
     "//src/tint/lang/core/type:test",
diff --git a/src/tint/cmd/test/BUILD.cmake b/src/tint/cmd/test/BUILD.cmake
index 0cb839e..94c5d90 100644
--- a/src/tint/cmd/test/BUILD.cmake
+++ b/src/tint/cmd/test/BUILD.cmake
@@ -49,6 +49,7 @@
   tint_cmd_common_test
   tint_lang_core_constant_test
   tint_lang_core_intrinsic_test
+  tint_lang_core_ir_transform_common_test
   tint_lang_core_ir_transform_test
   tint_lang_core_ir_test
   tint_lang_core_type_test
diff --git a/src/tint/cmd/test/BUILD.gn b/src/tint/cmd/test/BUILD.gn
index 54897e3..b8647d4 100644
--- a/src/tint/cmd/test/BUILD.gn
+++ b/src/tint/cmd/test/BUILD.gn
@@ -57,6 +57,7 @@
       "${tint_src_dir}/lang/core/intrinsic:unittests",
       "${tint_src_dir}/lang/core/ir:unittests",
       "${tint_src_dir}/lang/core/ir/transform:unittests",
+      "${tint_src_dir}/lang/core/ir/transform/common:unittests",
       "${tint_src_dir}/lang/core/type:unittests",
       "${tint_src_dir}/lang/hlsl/writer/common:unittests",
       "${tint_src_dir}/lang/msl/ir:unittests",
diff --git a/src/tint/lang/core/ir/access_test.cc b/src/tint/lang/core/ir/access_test.cc
index aee7288..072a953 100644
--- a/src/tint/lang/core/ir/access_test.cc
+++ b/src/tint/lang/core/ir/access_test.cc
@@ -62,7 +62,7 @@
 }
 
 TEST_F(IR_AccessTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/bitcast_test.cc b/src/tint/lang/core/ir/bitcast_test.cc
index c585375..debb722 100644
--- a/src/tint/lang/core/ir/bitcast_test.cc
+++ b/src/tint/lang/core/ir/bitcast_test.cc
@@ -72,7 +72,7 @@
 }
 
 TEST_F(IR_BitcastTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/block_param_test.cc b/src/tint/lang/core/ir/block_param_test.cc
index 96a8e02..d5f14e2 100644
--- a/src/tint/lang/core/ir/block_param_test.cc
+++ b/src/tint/lang/core/ir/block_param_test.cc
@@ -37,7 +37,7 @@
 using IR_BlockParamTest = IRTestHelper;
 
 TEST_F(IR_BlockParamTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/break_if_test.cc b/src/tint/lang/core/ir/break_if_test.cc
index 0e14900..d472c47 100644
--- a/src/tint/lang/core/ir/break_if_test.cc
+++ b/src/tint/lang/core/ir/break_if_test.cc
@@ -60,7 +60,7 @@
 }
 
 TEST_F(IR_BreakIfTest, Fail_NullLoop) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/builder.h b/src/tint/lang/core/ir/builder.h
index 99e94d1..84fc0ed 100644
--- a/src/tint/lang/core/ir/builder.h
+++ b/src/tint/lang/core/ir/builder.h
@@ -960,6 +960,19 @@
     /// @returns the instruction
     ir::Discard* Discard();
 
+    /// Creates a user function call instruction with an existing instruction result
+    /// @param result the instruction result to use
+    /// @param func the function to call
+    /// @param args the call arguments
+    /// @returns the instruction
+    template <typename... ARGS>
+    ir::UserCall* CallWithResult(ir::InstructionResult* result,
+                                 ir::Function* func,
+                                 ARGS&&... args) {
+        return Append(ir.allocators.instructions.Create<ir::UserCall>(
+            result, func, Values(std::forward<ARGS>(args)...)));
+    }
+
     /// Creates a user function call instruction
     /// @param func the function to call
     /// @param args the call arguments
@@ -976,8 +989,7 @@
     /// @returns the instruction
     template <typename... ARGS>
     ir::UserCall* Call(const core::type::Type* type, ir::Function* func, ARGS&&... args) {
-        return Append(ir.allocators.instructions.Create<ir::UserCall>(
-            InstructionResult(type), func, Values(std::forward<ARGS>(args)...)));
+        return CallWithResult(InstructionResult(type), func, Values(std::forward<ARGS>(args)...));
     }
 
     /// Creates a user function call instruction
@@ -988,8 +1000,20 @@
     template <typename TYPE, typename... ARGS>
     ir::UserCall* Call(ir::Function* func, ARGS&&... args) {
         auto* type = ir.Types().Get<TYPE>();
-        return Append(ir.allocators.instructions.Create<ir::UserCall>(
-            InstructionResult(type), func, Values(std::forward<ARGS>(args)...)));
+        return CallWithResult(InstructionResult(type), func, Values(std::forward<ARGS>(args)...));
+    }
+
+    /// Creates a core builtin call instruction with an existing instruction result
+    /// @param result the instruction result to use
+    /// @param func the builtin function to call
+    /// @param args the call arguments
+    /// @returns the instruction
+    template <typename... ARGS>
+    ir::CoreBuiltinCall* CallWithResult(core::ir::InstructionResult* result,
+                                        core::BuiltinFn func,
+                                        ARGS&&... args) {
+        return Append(ir.allocators.instructions.Create<ir::CoreBuiltinCall>(
+            result, func, Values(std::forward<ARGS>(args)...)));
     }
 
     /// Creates a core builtin call instruction
@@ -999,8 +1023,7 @@
     /// @returns the instruction
     template <typename... ARGS>
     ir::CoreBuiltinCall* Call(const core::type::Type* type, core::BuiltinFn func, ARGS&&... args) {
-        return Append(ir.allocators.instructions.Create<ir::CoreBuiltinCall>(
-            InstructionResult(type), func, Values(std::forward<ARGS>(args)...)));
+        return CallWithResult(InstructionResult(type), func, Values(std::forward<ARGS>(args)...));
     }
 
     /// Creates a core builtin call instruction
@@ -1011,11 +1034,22 @@
     template <typename TYPE, typename... ARGS>
     ir::CoreBuiltinCall* Call(core::BuiltinFn func, ARGS&&... args) {
         auto* type = ir.Types().Get<TYPE>();
-        return Append(ir.allocators.instructions.Create<ir::CoreBuiltinCall>(
-            InstructionResult(type), func, Values(std::forward<ARGS>(args)...)));
+        return CallWithResult(InstructionResult(type), func, Values(std::forward<ARGS>(args)...));
     }
 
-    /// Creates a core builtin call instruction
+    /// Creates a builtin call instruction with an existing instruction result
+    /// @param result the instruction result to use
+    /// @param func the builtin function to call
+    /// @param args the call arguments
+    /// @returns the instruction
+    template <typename KLASS, typename FUNC, typename... ARGS>
+    tint::traits::EnableIf<tint::traits::IsTypeOrDerived<KLASS, ir::BuiltinCall>, KLASS*>
+    CallWithResult(ir::InstructionResult* result, FUNC func, ARGS&&... args) {
+        return Append(ir.allocators.instructions.Create<KLASS>(
+            result, func, Values(std::forward<ARGS>(args)...)));
+    }
+
+    /// Creates a builtin call instruction
     /// @param type the return type of the call
     /// @param func the builtin function to call
     /// @param args the call arguments
@@ -1023,8 +1057,8 @@
     template <typename KLASS, typename FUNC, typename... ARGS>
     tint::traits::EnableIf<tint::traits::IsTypeOrDerived<KLASS, ir::BuiltinCall>, KLASS*>
     Call(const core::type::Type* type, FUNC func, ARGS&&... args) {
-        return Append(ir.allocators.instructions.Create<KLASS>(
-            InstructionResult(type), func, Values(std::forward<ARGS>(args)...)));
+        return CallWithResult<KLASS>(InstructionResult(type), func,
+                                     Values(std::forward<ARGS>(args)...));
     }
 
     /// Creates a value conversion instruction to the template type T
@@ -1046,6 +1080,16 @@
             InstructionResult(to), Value(std::forward<VAL>(val))));
     }
 
+    /// Creates a value constructor instruction with an existing instruction result
+    /// @param result the instruction result to use
+    /// @param args the arguments to the constructor
+    /// @returns the instruction
+    template <typename... ARGS>
+    ir::Construct* ConstructWithResult(ir::InstructionResult* result, ARGS&&... args) {
+        return Append(ir.allocators.instructions.Create<ir::Construct>(
+            result, Values(std::forward<ARGS>(args)...)));
+    }
+
     /// Creates a value constructor instruction to the template type T
     /// @param args the arguments to the constructor
     /// @returns the instruction
@@ -1061,8 +1105,17 @@
     /// @returns the instruction
     template <typename... ARGS>
     ir::Construct* Construct(const core::type::Type* type, ARGS&&... args) {
-        return Append(ir.allocators.instructions.Create<ir::Construct>(
-            InstructionResult(type), Values(std::forward<ARGS>(args)...)));
+        return ConstructWithResult(InstructionResult(type), Values(std::forward<ARGS>(args)...));
+    }
+
+    /// Creates a load instruction with an existing result
+    /// @param result the instruction result to use
+    /// @param from the expression being loaded from
+    /// @returns the instruction
+    template <typename VAL>
+    ir::Load* LoadWithResult(ir::InstructionResult* result, VAL&& from) {
+        auto* value = Value(std::forward<VAL>(from));
+        return Append(ir.allocators.instructions.Create<ir::Load>(result, value));
     }
 
     /// Creates a load instruction
@@ -1071,8 +1124,7 @@
     template <typename VAL>
     ir::Load* Load(VAL&& from) {
         auto* value = Value(std::forward<VAL>(from));
-        return Append(ir.allocators.instructions.Create<ir::Load>(
-            InstructionResult(value->Type()->UnwrapPtrOrRef()), value));
+        return LoadWithResult(InstructionResult(value->Type()->UnwrapPtrOrRef()), value);
     }
 
     /// Creates a store instruction
@@ -1102,6 +1154,22 @@
                                                                                 value_val));
     }
 
+    /// Creates a load vector element instruction with an existing instruction result
+    /// @param result the instruction result to use
+    /// @param from the vector pointer expression being loaded from
+    /// @param index the new vector element index
+    /// @returns the instruction
+    template <typename FROM, typename INDEX>
+    ir::LoadVectorElement* LoadVectorElementWithResult(ir::InstructionResult* result,
+                                                       FROM&& from,
+                                                       INDEX&& index) {
+        CheckForNonDeterministicEvaluation<FROM, INDEX>();
+        auto* from_val = Value(std::forward<FROM>(from));
+        auto* index_val = Value(std::forward<INDEX>(index));
+        return Append(
+            ir.allocators.instructions.Create<ir::LoadVectorElement>(result, from_val, index_val));
+    }
+
     /// Creates a load vector element instruction
     /// @param from the vector pointer expression being loaded from
     /// @param index the new vector element index
@@ -1112,8 +1180,7 @@
         auto* from_val = Value(std::forward<FROM>(from));
         auto* index_val = Value(std::forward<INDEX>(index));
         auto* res = InstructionResult(VectorPtrElementType(from_val->Type()));
-        return Append(
-            ir.allocators.instructions.Create<ir::LoadVectorElement>(res, from_val, index_val));
+        return LoadVectorElementWithResult(res, from_val, index_val);
     }
 
     /// Creates a new `var` declaration
@@ -1351,6 +1418,19 @@
         return FunctionParam(type);
     }
 
+    /// Creates a new `Access` with an existing instruction result
+    /// @param result the instruction result to use
+    /// @param object the object being accessed
+    /// @param indices the access indices
+    /// @returns the instruction
+    template <typename OBJ, typename... ARGS>
+    ir::Access* AccessWithResult(ir::InstructionResult* result, OBJ&& object, ARGS&&... indices) {
+        CheckForNonDeterministicEvaluation<OBJ, ARGS...>();
+        auto* obj_val = Value(std::forward<OBJ>(object));
+        return Append(ir.allocators.instructions.Create<ir::Access>(
+            result, obj_val, Values(std::forward<ARGS>(indices)...)));
+    }
+
     /// Creates a new `Access`
     /// @param type the return type
     /// @param object the object being accessed
@@ -1358,10 +1438,8 @@
     /// @returns the instruction
     template <typename OBJ, typename... ARGS>
     ir::Access* Access(const core::type::Type* type, OBJ&& object, ARGS&&... indices) {
-        CheckForNonDeterministicEvaluation<OBJ, ARGS...>();
-        auto* obj_val = Value(std::forward<OBJ>(object));
-        return Append(ir.allocators.instructions.Create<ir::Access>(
-            InstructionResult(type), obj_val, Values(std::forward<ARGS>(indices)...)));
+        return AccessWithResult(InstructionResult(type), std::forward<OBJ>(object),
+                                Values(std::forward<ARGS>(indices)...));
     }
 
     /// Creates a new `Access`
diff --git a/src/tint/lang/core/ir/constant_test.cc b/src/tint/lang/core/ir/constant_test.cc
index c8b6ef1..e960936 100644
--- a/src/tint/lang/core/ir/constant_test.cc
+++ b/src/tint/lang/core/ir/constant_test.cc
@@ -112,11 +112,11 @@
 }
 
 TEST_F(IR_ConstantTest, Fail_NullValue) {
-    EXPECT_DEATH({ Constant c(nullptr); }, "");
+    EXPECT_DEATH_IF_SUPPORTED({ Constant c(nullptr); }, "");
 }
 
 TEST_F(IR_ConstantTest, Fail_Builder_NullValue) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/construct_test.cc b/src/tint/lang/core/ir/construct_test.cc
index 6e4b005..d8cef8e 100644
--- a/src/tint/lang/core/ir/construct_test.cc
+++ b/src/tint/lang/core/ir/construct_test.cc
@@ -56,7 +56,7 @@
 }
 
 TEST_F(IR_ConstructTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/continue_test.cc b/src/tint/lang/core/ir/continue_test.cc
index 79875cf..d4d9c6b 100644
--- a/src/tint/lang/core/ir/continue_test.cc
+++ b/src/tint/lang/core/ir/continue_test.cc
@@ -58,7 +58,7 @@
 }
 
 TEST_F(IR_ContinueTest, Fail_NullLoop) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/convert_test.cc b/src/tint/lang/core/ir/convert_test.cc
index 73c3076..321809c 100644
--- a/src/tint/lang/core/ir/convert_test.cc
+++ b/src/tint/lang/core/ir/convert_test.cc
@@ -35,7 +35,7 @@
 using IR_ConvertTest = IRTestHelper;
 
 TEST_F(IR_ConvertTest, Fail_NullToType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/core_binary_test.cc b/src/tint/lang/core/ir/core_binary_test.cc
index 6fde891..6ce32d7 100644
--- a/src/tint/lang/core/ir/core_binary_test.cc
+++ b/src/tint/lang/core/ir/core_binary_test.cc
@@ -40,7 +40,7 @@
 using IR_BinaryTest = IRTestHelper;
 
 TEST_F(IR_BinaryTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/core_builtin_call_test.cc b/src/tint/lang/core/ir/core_builtin_call_test.cc
index 7897862..bfc897e 100644
--- a/src/tint/lang/core/ir/core_builtin_call_test.cc
+++ b/src/tint/lang/core/ir/core_builtin_call_test.cc
@@ -55,7 +55,7 @@
 }
 
 TEST_F(IR_CoreBuiltinCallTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -65,7 +65,7 @@
 }
 
 TEST_F(IR_CoreBuiltinCallTest, Fail_NoneFunction) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/core_unary_test.cc b/src/tint/lang/core/ir/core_unary_test.cc
index 88de1c1..26054a0 100644
--- a/src/tint/lang/core/ir/core_unary_test.cc
+++ b/src/tint/lang/core/ir/core_unary_test.cc
@@ -80,7 +80,7 @@
 }
 
 TEST_F(IR_UnaryTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/disassembly.cc b/src/tint/lang/core/ir/disassembly.cc
index a07033f..df0cc4e 100644
--- a/src/tint/lang/core/ir/disassembly.cc
+++ b/src/tint/lang/core/ir/disassembly.cc
@@ -30,6 +30,7 @@
 #include <string_view>
 
 #include "src//tint/lang/core/ir/unary.h"
+#include "src/tint/lang/core/binary_op.h"
 #include "src/tint/lang/core/constant/composite.h"
 #include "src/tint/lang/core/constant/scalar.h"
 #include "src/tint/lang/core/constant/splat.h"
@@ -796,64 +797,7 @@
 void Disassembly::EmitBinary(const Binary* b) {
     SourceMarker sm(this);
     EmitValueWithType(b);
-    out_ << " = ";
-    switch (b->Op()) {
-        case BinaryOp::kAdd:
-            out_ << StyleInstruction("add");
-            break;
-        case BinaryOp::kSubtract:
-            out_ << StyleInstruction("sub");
-            break;
-        case BinaryOp::kMultiply:
-            out_ << StyleInstruction("mul");
-            break;
-        case BinaryOp::kDivide:
-            out_ << StyleInstruction("div");
-            break;
-        case BinaryOp::kModulo:
-            out_ << StyleInstruction("mod");
-            break;
-        case BinaryOp::kAnd:
-            out_ << StyleInstruction("and");
-            break;
-        case BinaryOp::kOr:
-            out_ << StyleInstruction("or");
-            break;
-        case BinaryOp::kXor:
-            out_ << StyleInstruction("xor");
-            break;
-        case BinaryOp::kEqual:
-            out_ << StyleInstruction("eq");
-            break;
-        case BinaryOp::kNotEqual:
-            out_ << StyleInstruction("neq");
-            break;
-        case BinaryOp::kLessThan:
-            out_ << StyleInstruction("lt");
-            break;
-        case BinaryOp::kGreaterThan:
-            out_ << StyleInstruction("gt");
-            break;
-        case BinaryOp::kLessThanEqual:
-            out_ << StyleInstruction("lte");
-            break;
-        case BinaryOp::kGreaterThanEqual:
-            out_ << StyleInstruction("gte");
-            break;
-        case BinaryOp::kShiftLeft:
-            out_ << StyleInstruction("shl");
-            break;
-        case BinaryOp::kShiftRight:
-            out_ << StyleInstruction("shr");
-            break;
-        case BinaryOp::kLogicalAnd:
-            out_ << StyleInstruction("logical-and");
-            break;
-        case BinaryOp::kLogicalOr:
-            out_ << StyleInstruction("logical-or");
-            break;
-    }
-    out_ << " ";
+    out_ << " = " << NameOf(b->Op()) << " ";
     EmitOperandList(b);
 
     sm.Store(b);
@@ -862,25 +806,7 @@
 void Disassembly::EmitUnary(const Unary* u) {
     SourceMarker sm(this);
     EmitValueWithType(u);
-    out_ << " = ";
-    switch (u->Op()) {
-        case UnaryOp::kComplement:
-            out_ << StyleInstruction("complement");
-            break;
-        case UnaryOp::kNegation:
-            out_ << StyleInstruction("negation");
-            break;
-        case UnaryOp::kAddressOf:
-            out_ << StyleInstruction("ref-to-ptr");
-            break;
-        case UnaryOp::kIndirection:
-            out_ << StyleInstruction("ptr-to-ref");
-            break;
-        case UnaryOp::kNot:
-            out_ << StyleInstruction("not");
-            break;
-    }
-    out_ << " ";
+    out_ << " = " << NameOf(u->Op()) << " ";
     EmitOperandList(u);
 
     sm.Store(u);
@@ -984,4 +910,62 @@
     return StyledText{} << StyleInstruction(name);
 }
 
+StyledText Disassembly::NameOf(BinaryOp op) {
+    switch (op) {
+        case BinaryOp::kAdd:
+            return StyledText{} << StyleInstruction("add");
+        case BinaryOp::kSubtract:
+            return StyledText{} << StyleInstruction("sub");
+        case BinaryOp::kMultiply:
+            return StyledText{} << StyleInstruction("mul");
+        case BinaryOp::kDivide:
+            return StyledText{} << StyleInstruction("div");
+        case BinaryOp::kModulo:
+            return StyledText{} << StyleInstruction("mod");
+        case BinaryOp::kAnd:
+            return StyledText{} << StyleInstruction("and");
+        case BinaryOp::kOr:
+            return StyledText{} << StyleInstruction("or");
+        case BinaryOp::kXor:
+            return StyledText{} << StyleInstruction("xor");
+        case BinaryOp::kEqual:
+            return StyledText{} << StyleInstruction("eq");
+        case BinaryOp::kNotEqual:
+            return StyledText{} << StyleInstruction("neq");
+        case BinaryOp::kLessThan:
+            return StyledText{} << StyleInstruction("lt");
+        case BinaryOp::kGreaterThan:
+            return StyledText{} << StyleInstruction("gt");
+        case BinaryOp::kLessThanEqual:
+            return StyledText{} << StyleInstruction("lte");
+        case BinaryOp::kGreaterThanEqual:
+            return StyledText{} << StyleInstruction("gte");
+        case BinaryOp::kShiftLeft:
+            return StyledText{} << StyleInstruction("shl");
+        case BinaryOp::kShiftRight:
+            return StyledText{} << StyleInstruction("shr");
+        case BinaryOp::kLogicalAnd:
+            return StyledText{} << StyleInstruction("logical-and");
+        case BinaryOp::kLogicalOr:
+            return StyledText{} << StyleInstruction("logical-or");
+    }
+    TINT_UNREACHABLE() << op;
+}
+
+StyledText Disassembly::NameOf(UnaryOp op) {
+    switch (op) {
+        case UnaryOp::kComplement:
+            return StyledText{} << StyleInstruction("complement");
+        case UnaryOp::kNegation:
+            return StyledText{} << StyleInstruction("negation");
+        case UnaryOp::kAddressOf:
+            return StyledText{} << StyleInstruction("ref-to-ptr");
+        case UnaryOp::kIndirection:
+            return StyledText{} << StyleInstruction("ptr-to-ref");
+        case UnaryOp::kNot:
+            return StyledText{} << StyleInstruction("not");
+    }
+    TINT_UNREACHABLE() << op;
+}
+
 }  // namespace tint::core::ir
diff --git a/src/tint/lang/core/ir/disassembly.h b/src/tint/lang/core/ir/disassembly.h
index 677b386..70c48b4 100644
--- a/src/tint/lang/core/ir/disassembly.h
+++ b/src/tint/lang/core/ir/disassembly.h
@@ -32,6 +32,7 @@
 #include <string>
 #include <string_view>
 
+#include "src/tint/lang/core/binary_op.h"
 #include "src/tint/lang/core/ir/binary.h"
 #include "src/tint/lang/core/ir/block.h"
 #include "src/tint/lang/core/ir/block_param.h"
@@ -108,6 +109,12 @@
     /// @returns the disassembled name for the Switch @p inst
     StyledText NameOf(const Switch* inst);
 
+    /// @returns the disassembled name for the BinaryOp @p op
+    StyledText NameOf(BinaryOp op);
+
+    /// @returns the disassembled name for the UnaryOp @p op
+    StyledText NameOf(UnaryOp op);
+
     /// @param inst the instruction to retrieve
     /// @returns the source for the instruction
     Source InstructionSource(const Instruction* inst) const {
diff --git a/src/tint/lang/core/ir/function_param_test.cc b/src/tint/lang/core/ir/function_param_test.cc
index 49bc373..80b150f 100644
--- a/src/tint/lang/core/ir/function_param_test.cc
+++ b/src/tint/lang/core/ir/function_param_test.cc
@@ -37,7 +37,7 @@
 using IR_FunctionParamTest = IRTestHelper;
 
 TEST_F(IR_FunctionParamTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -47,7 +47,7 @@
 }
 
 TEST_F(IR_FunctionParamTest, Fail_SetDuplicateBuiltin) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/function_test.cc b/src/tint/lang/core/ir/function_test.cc
index 19443f6..9f0161a 100644
--- a/src/tint/lang/core/ir/function_test.cc
+++ b/src/tint/lang/core/ir/function_test.cc
@@ -37,7 +37,7 @@
 using IR_FunctionTest = IRTestHelper;
 
 TEST_F(IR_FunctionTest, Fail_NullReturnType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -47,7 +47,7 @@
 }
 
 TEST_F(IR_FunctionTest, Fail_DoubleReturnBuiltin) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -59,7 +59,7 @@
 }
 
 TEST_F(IR_FunctionTest, Fail_NullParam) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -70,7 +70,7 @@
 }
 
 TEST_F(IR_FunctionTest, Fail_NullBlock) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/if_test.cc b/src/tint/lang/core/ir/if_test.cc
index cb02e52..236a47d 100644
--- a/src/tint/lang/core/ir/if_test.cc
+++ b/src/tint/lang/core/ir/if_test.cc
@@ -55,7 +55,7 @@
 }
 
 TEST_F(IR_IfTest, Fail_NullTrueBlock) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -65,7 +65,7 @@
 }
 
 TEST_F(IR_IfTest, Fail_NullFalseBlock) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/instruction.cc b/src/tint/lang/core/ir/instruction.cc
index 8d5218c..3f99d68 100644
--- a/src/tint/lang/core/ir/instruction.cc
+++ b/src/tint/lang/core/ir/instruction.cc
@@ -73,4 +73,11 @@
     Block()->Remove(this);
 }
 
+InstructionResult* Instruction::DetachResult() {
+    TINT_ASSERT(Results().Length() == 1u);
+    auto* result = Results()[0];
+    SetResults({});
+    return result;
+}
+
 }  // namespace tint::core::ir
diff --git a/src/tint/lang/core/ir/instruction.h b/src/tint/lang/core/ir/instruction.h
index f8c72d5..cff4ab0 100644
--- a/src/tint/lang/core/ir/instruction.h
+++ b/src/tint/lang/core/ir/instruction.h
@@ -115,6 +115,10 @@
     /// Removes this instruction from the owning block
     void Remove();
 
+    /// Detach an instruction result from this instruction.
+    /// @returns the instruction result that was detached
+    InstructionResult* DetachResult();
+
     /// @param idx the index of the operand
     /// @returns the operand with index @p idx, or `nullptr` if there are no operands or the index
     /// is out of bounds.
diff --git a/src/tint/lang/core/ir/instruction_result_test.cc b/src/tint/lang/core/ir/instruction_result_test.cc
index 665f17c..01aff8d 100644
--- a/src/tint/lang/core/ir/instruction_result_test.cc
+++ b/src/tint/lang/core/ir/instruction_result_test.cc
@@ -37,7 +37,7 @@
 using IR_InstructionResultTest = IRTestHelper;
 
 TEST_F(IR_InstructionResultTest, Destroy_HasInstruction) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/instruction_test.cc b/src/tint/lang/core/ir/instruction_test.cc
index fa95ba5..3813711 100644
--- a/src/tint/lang/core/ir/instruction_test.cc
+++ b/src/tint/lang/core/ir/instruction_test.cc
@@ -30,6 +30,8 @@
 #include "src/tint/lang/core/ir/ir_helper_test.h"
 #include "src/tint/lang/core/ir/module.h"
 
+using namespace tint::core::number_suffixes;  // NOLINT
+
 namespace tint::core::ir {
 namespace {
 
@@ -46,7 +48,7 @@
 }
 
 TEST_F(IR_InstructionTest, Fail_InsertBeforeNullptr) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -58,7 +60,7 @@
 }
 
 TEST_F(IR_InstructionTest, Fail_InsertBeforeNotInserted) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -81,7 +83,7 @@
 }
 
 TEST_F(IR_InstructionTest, Fail_InsertAfterNullptr) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -93,7 +95,7 @@
 }
 
 TEST_F(IR_InstructionTest, Fail_InsertAfterNotInserted) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -117,7 +119,7 @@
 }
 
 TEST_F(IR_InstructionTest, Fail_ReplaceWithNullptr) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -131,7 +133,7 @@
 }
 
 TEST_F(IR_InstructionTest, Fail_ReplaceWithNotInserted) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -155,7 +157,7 @@
 }
 
 TEST_F(IR_InstructionTest, Fail_RemoveNotInserted) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -166,5 +168,16 @@
         "");
 }
 
+TEST_F(IR_InstructionTest, DetachResult) {
+    auto* inst = b.Let("foo", 42_u);
+    auto* result = inst->Result(0);
+    EXPECT_EQ(result->Instruction(), inst);
+
+    auto* detached = inst->DetachResult();
+    EXPECT_EQ(detached, result);
+    EXPECT_EQ(detached->Instruction(), nullptr);
+    EXPECT_EQ(inst->Results().Length(), 0u);
+}
+
 }  // namespace
 }  // namespace tint::core::ir
diff --git a/src/tint/lang/core/ir/let_test.cc b/src/tint/lang/core/ir/let_test.cc
index 218bd1e..3d998d6 100644
--- a/src/tint/lang/core/ir/let_test.cc
+++ b/src/tint/lang/core/ir/let_test.cc
@@ -41,7 +41,7 @@
 using IR_LetTest = IRTestHelper;
 
 TEST_F(IR_LetTest, Fail_NullValue) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/loop_test.cc b/src/tint/lang/core/ir/loop_test.cc
index 0f522a0..6a7e214 100644
--- a/src/tint/lang/core/ir/loop_test.cc
+++ b/src/tint/lang/core/ir/loop_test.cc
@@ -47,7 +47,7 @@
 }
 
 TEST_F(IR_LoopTest, Fail_NullInitializerBlock) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -57,7 +57,7 @@
 }
 
 TEST_F(IR_LoopTest, Fail_NullBodyBlock) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -67,7 +67,7 @@
 }
 
 TEST_F(IR_LoopTest, Fail_NullContinuingBlock) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/multi_in_block_test.cc b/src/tint/lang/core/ir/multi_in_block_test.cc
index 5d4d459..583f544 100644
--- a/src/tint/lang/core/ir/multi_in_block_test.cc
+++ b/src/tint/lang/core/ir/multi_in_block_test.cc
@@ -36,7 +36,7 @@
 using IR_MultiInBlockTest = IRTestHelper;
 
 TEST_F(IR_MultiInBlockTest, Fail_NullInboundBranch) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/next_iteration_test.cc b/src/tint/lang/core/ir/next_iteration_test.cc
index e1554cb..89b4d40 100644
--- a/src/tint/lang/core/ir/next_iteration_test.cc
+++ b/src/tint/lang/core/ir/next_iteration_test.cc
@@ -35,7 +35,7 @@
 using IR_NextIterationTest = IRTestHelper;
 
 TEST_F(IR_NextIterationTest, Fail_NullLoop) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/swizzle_test.cc b/src/tint/lang/core/ir/swizzle_test.cc
index ba911f7..6d26ab0 100644
--- a/src/tint/lang/core/ir/swizzle_test.cc
+++ b/src/tint/lang/core/ir/swizzle_test.cc
@@ -54,7 +54,7 @@
 }
 
 TEST_F(IR_SwizzleTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -65,7 +65,7 @@
 }
 
 TEST_F(IR_SwizzleTest, Fail_EmptyIndices) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -76,7 +76,7 @@
 }
 
 TEST_F(IR_SwizzleTest, Fail_TooManyIndices) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
@@ -87,7 +87,7 @@
 }
 
 TEST_F(IR_SwizzleTest, Fail_IndexOutOfRange) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/transform/BUILD.bazel b/src/tint/lang/core/ir/transform/BUILD.bazel
index 467e451..3ea71c4 100644
--- a/src/tint/lang/core/ir/transform/BUILD.bazel
+++ b/src/tint/lang/core/ir/transform/BUILD.bazel
@@ -85,6 +85,7 @@
     "//src/tint/lang/core/constant",
     "//src/tint/lang/core/intrinsic",
     "//src/tint/lang/core/ir",
+    "//src/tint/lang/core/ir/transform/common",
     "//src/tint/lang/core/type",
     "//src/tint/utils/containers",
     "//src/tint/utils/diagnostic",
diff --git a/src/tint/lang/core/ir/transform/BUILD.cmake b/src/tint/lang/core/ir/transform/BUILD.cmake
index 1ae8efb..f3a8b32 100644
--- a/src/tint/lang/core/ir/transform/BUILD.cmake
+++ b/src/tint/lang/core/ir/transform/BUILD.cmake
@@ -34,6 +34,8 @@
 #                       Do not modify this file directly
 ################################################################################
 
+include(lang/core/ir/transform/common/BUILD.cmake)
+
 ################################################################################
 # Target:    tint_lang_core_ir_transform
 # Kind:      lib
@@ -84,6 +86,7 @@
   tint_lang_core_constant
   tint_lang_core_intrinsic
   tint_lang_core_ir
+  tint_lang_core_ir_transform_common
   tint_lang_core_type
   tint_utils_containers
   tint_utils_diagnostic
diff --git a/src/tint/lang/core/ir/transform/BUILD.gn b/src/tint/lang/core/ir/transform/BUILD.gn
index 95de9fc..db194b7 100644
--- a/src/tint/lang/core/ir/transform/BUILD.gn
+++ b/src/tint/lang/core/ir/transform/BUILD.gn
@@ -88,6 +88,7 @@
     "${tint_src_dir}/lang/core/constant",
     "${tint_src_dir}/lang/core/intrinsic",
     "${tint_src_dir}/lang/core/ir",
+    "${tint_src_dir}/lang/core/ir/transform/common",
     "${tint_src_dir}/lang/core/type",
     "${tint_src_dir}/utils/containers",
     "${tint_src_dir}/utils/diagnostic",
diff --git a/src/tint/lang/core/ir/transform/binary_polyfill.cc b/src/tint/lang/core/ir/transform/binary_polyfill.cc
index 7d4a962..a5aec3c 100644
--- a/src/tint/lang/core/ir/transform/binary_polyfill.cc
+++ b/src/tint/lang/core/ir/transform/binary_polyfill.cc
@@ -91,29 +91,18 @@
 
         // Polyfill the binary instructions that we found.
         for (auto* binary : worklist) {
-            ir::Value* replacement = nullptr;
             switch (binary->Op()) {
                 case BinaryOp::kDivide:
                 case BinaryOp::kModulo:
-                    replacement = IntDivMod(binary);
+                    IntDivMod(binary);
                     break;
                 case BinaryOp::kShiftLeft:
                 case BinaryOp::kShiftRight:
-                    replacement = MaskShiftAmount(binary);
+                    MaskShiftAmount(binary);
                     break;
                 default:
                     break;
             }
-            TINT_ASSERT(replacement);
-
-            if (replacement != binary->Result(0)) {
-                // Replace the old binary instruction result with the new value.
-                if (auto name = ir.NameOf(binary->Result(0))) {
-                    ir.SetName(replacement, name);
-                }
-                binary->Result(0)->ReplaceAllUsesWith(replacement);
-                binary->Destroy();
-            }
         }
     }
 
@@ -145,8 +134,7 @@
     /// Replace an integer divide or modulo with a call to helper function that prevents
     /// divide-by-zero and signed integer overflow.
     /// @param binary the binary instruction
-    /// @returns the replacement value
-    ir::Value* IntDivMod(ir::CoreBinary* binary) {
+    void IntDivMod(ir::CoreBinary* binary) {
         auto* result_ty = binary->Result(0)->Type();
         bool is_div = binary->Op() == BinaryOp::kDivide;
         bool is_signed = result_ty->is_signed_integer_scalar_or_vector();
@@ -217,26 +205,23 @@
         };
 
         // Call the helper function, splatting the arguments to match the target vector width.
-        Value* result = nullptr;
         b.InsertBefore(binary, [&] {
             auto* lhs = maybe_splat(binary->LHS());
             auto* rhs = maybe_splat(binary->RHS());
-            result = b.Call(result_ty, helper, lhs, rhs)->Result(0);
+            b.CallWithResult(binary->DetachResult(), helper, lhs, rhs);
         });
-        return result;
+        binary->Destroy();
     }
 
     /// Mask the RHS of a shift instruction to ensure it is modulo the bitwidth of the LHS.
     /// @param binary the binary instruction
-    /// @returns the replacement value
-    ir::Value* MaskShiftAmount(ir::CoreBinary* binary) {
+    void MaskShiftAmount(ir::CoreBinary* binary) {
         auto* lhs = binary->LHS();
         auto* rhs = binary->RHS();
         auto* mask = b.Constant(u32(lhs->Type()->DeepestElement()->Size() * 8 - 1));
         auto* masked = b.And(rhs->Type(), rhs, MatchWidth(mask, rhs->Type()));
         masked->InsertBefore(binary);
         binary->SetOperand(ir::CoreBinary::kRhsOperandOffset, masked->Result(0));
-        return binary->Result(0);
     }
 };
 
diff --git a/src/tint/lang/core/ir/transform/builtin_polyfill.cc b/src/tint/lang/core/ir/transform/builtin_polyfill.cc
index 0eb45f8..ef68d5b 100644
--- a/src/tint/lang/core/ir/transform/builtin_polyfill.cc
+++ b/src/tint/lang/core/ir/transform/builtin_polyfill.cc
@@ -147,72 +147,61 @@
 
         // Polyfill the builtin call instructions that we found.
         for (auto* builtin : worklist) {
-            ir::Value* replacement = nullptr;
             switch (builtin->Func()) {
                 case core::BuiltinFn::kClamp:
-                    replacement = ClampInt(builtin);
+                    ClampInt(builtin);
                     break;
                 case core::BuiltinFn::kCountLeadingZeros:
-                    replacement = CountLeadingZeros(builtin);
+                    CountLeadingZeros(builtin);
                     break;
                 case core::BuiltinFn::kCountTrailingZeros:
-                    replacement = CountTrailingZeros(builtin);
+                    CountTrailingZeros(builtin);
                     break;
                 case core::BuiltinFn::kExtractBits:
-                    replacement = ExtractBits(builtin);
+                    ExtractBits(builtin);
                     break;
                 case core::BuiltinFn::kFirstLeadingBit:
-                    replacement = FirstLeadingBit(builtin);
+                    FirstLeadingBit(builtin);
                     break;
                 case core::BuiltinFn::kFirstTrailingBit:
-                    replacement = FirstTrailingBit(builtin);
+                    FirstTrailingBit(builtin);
                     break;
                 case core::BuiltinFn::kInsertBits:
-                    replacement = InsertBits(builtin);
+                    InsertBits(builtin);
                     break;
                 case core::BuiltinFn::kSaturate:
-                    replacement = Saturate(builtin);
+                    Saturate(builtin);
                     break;
                 case core::BuiltinFn::kTextureSampleBaseClampToEdge:
-                    replacement = TextureSampleBaseClampToEdge_2d_f32(builtin);
+                    TextureSampleBaseClampToEdge_2d_f32(builtin);
                     break;
                 case core::BuiltinFn::kDot4I8Packed:
-                    replacement = Dot4I8Packed(builtin);
+                    Dot4I8Packed(builtin);
                     break;
                 case core::BuiltinFn::kDot4U8Packed:
-                    replacement = Dot4U8Packed(builtin);
+                    Dot4U8Packed(builtin);
                     break;
                 case core::BuiltinFn::kPack4XI8:
-                    replacement = Pack4xI8(builtin);
+                    Pack4xI8(builtin);
                     break;
                 case core::BuiltinFn::kPack4XU8:
-                    replacement = Pack4xU8(builtin);
+                    Pack4xU8(builtin);
                     break;
                 case core::BuiltinFn::kPack4XI8Clamp:
-                    replacement = Pack4xI8Clamp(builtin);
+                    Pack4xI8Clamp(builtin);
                     break;
                 case core::BuiltinFn::kPack4XU8Clamp:
-                    replacement = Pack4xU8Clamp(builtin);
+                    Pack4xU8Clamp(builtin);
                     break;
                 case core::BuiltinFn::kUnpack4XI8:
-                    replacement = Unpack4xI8(builtin);
+                    Unpack4xI8(builtin);
                     break;
                 case core::BuiltinFn::kUnpack4XU8:
-                    replacement = Unpack4xU8(builtin);
+                    Unpack4xU8(builtin);
                     break;
                 default:
                     break;
             }
-            TINT_ASSERT(replacement);
-
-            if (replacement != builtin->Result(0)) {
-                // Replace the old builtin call result with the new value.
-                if (auto name = ir.NameOf(builtin->Result(0))) {
-                    ir.SetName(replacement, name);
-                }
-                builtin->Result(0)->ReplaceAllUsesWith(replacement);
-                builtin->Destroy();
-            }
         }
     }
 
@@ -243,26 +232,22 @@
 
     /// Polyfill a `clamp()` builtin call for integers.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* ClampInt(ir::CoreBuiltinCall* call) {
+    void ClampInt(ir::CoreBuiltinCall* call) {
         auto* type = call->Result(0)->Type();
         auto* e = call->Args()[0];
         auto* low = call->Args()[1];
         auto* high = call->Args()[2];
 
-        Value* result = nullptr;
         b.InsertBefore(call, [&] {
             auto* max = b.Call(type, core::BuiltinFn::kMax, e, low);
-            auto* min = b.Call(type, core::BuiltinFn::kMin, max, high);
-            result = min->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kMin, max, high);
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `countLeadingZeros()` builtin call.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* CountLeadingZeros(ir::CoreBuiltinCall* call) {
+    void CountLeadingZeros(ir::CoreBuiltinCall* call) {
         auto* input = call->Args()[0];
         auto* result_ty = input->Type();
         auto* uint_ty = MatchWidth(ty.u32(), result_ty);
@@ -271,7 +256,6 @@
         // Make an u32 constant with the same component count as result_ty.
         auto V = [&](uint32_t u) { return MatchWidth(b.Constant(u32(u)), result_ty); };
 
-        Value* result = nullptr;
         b.InsertBefore(call, [&] {
             // %x = %input;
             // if (%x is signed) {
@@ -309,23 +293,23 @@
                               b.LessThanEqual(bool_ty, x, V(0x7fffffff)));
             auto* b0 =
                 b.Call(uint_ty, core::BuiltinFn::kSelect, V(0), V(1), b.Equal(bool_ty, x, V(0)));
-            result = b.Add(uint_ty,
-                           b.Or(uint_ty, b16,
-                                b.Or(uint_ty, b8,
-                                     b.Or(uint_ty, b4, b.Or(uint_ty, b2, b.Or(uint_ty, b1, b0))))),
-                           b0)
-                         ->Result(0);
+            Instruction* result = b.Add(
+                uint_ty,
+                b.Or(
+                    uint_ty, b16,
+                    b.Or(uint_ty, b8, b.Or(uint_ty, b4, b.Or(uint_ty, b2, b.Or(uint_ty, b1, b0))))),
+                b0);
             if (result_ty->is_signed_integer_scalar_or_vector()) {
-                result = b.Bitcast(result_ty, result)->Result(0);
+                result = b.Bitcast(result_ty, result);
             }
+            result->SetResults(Vector{call->DetachResult()});
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `countTrailingZeros()` builtin call.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* CountTrailingZeros(ir::CoreBuiltinCall* call) {
+    void CountTrailingZeros(ir::CoreBuiltinCall* call) {
         auto* input = call->Args()[0];
         auto* result_ty = input->Type();
         auto* uint_ty = MatchWidth(ty.u32(), result_ty);
@@ -334,7 +318,6 @@
         // Make an u32 constant with the same component count as result_ty.
         auto V = [&](uint32_t u) { return MatchWidth(b.Constant(u32(u)), result_ty); };
 
-        Value* result = nullptr;
         b.InsertBefore(call, [&] {
             // %x = %input;
             // if (%x is signed) {
@@ -372,22 +355,21 @@
                               b.Equal(bool_ty, b.And(uint_ty, x, V(0x00000001)), V(0)));
             auto* b0 =
                 b.Call(uint_ty, core::BuiltinFn::kSelect, V(0), V(1), b.Equal(bool_ty, x, V(0)));
-            result = b.Add(uint_ty,
-                           b.Or(uint_ty, b16,
-                                b.Or(uint_ty, b8, b.Or(uint_ty, b4, b.Or(uint_ty, b2, b1)))),
-                           b0)
-                         ->Result(0);
+            Instruction* result = b.Add(
+                uint_ty,
+                b.Or(uint_ty, b16, b.Or(uint_ty, b8, b.Or(uint_ty, b4, b.Or(uint_ty, b2, b1)))),
+                b0);
             if (result_ty->is_signed_integer_scalar_or_vector()) {
-                result = b.Bitcast(result_ty, result)->Result(0);
+                result = b.Bitcast(result_ty, result);
             }
+            result->SetResults(Vector{call->DetachResult()});
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill an `extractBits()` builtin call.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* ExtractBits(ir::CoreBuiltinCall* call) {
+    void ExtractBits(ir::CoreBuiltinCall* call) {
         auto* offset = call->Args()[1];
         auto* count = call->Args()[2];
 
@@ -406,7 +388,7 @@
                     call->SetOperand(ir::CoreBuiltinCall::kArgsOperandOffset + 1, o->Result(0));
                     call->SetOperand(ir::CoreBuiltinCall::kArgsOperandOffset + 2, c->Result(0));
                 });
-                return call->Result(0);
+                break;
             }
             default:
                 TINT_UNIMPLEMENTED() << "extractBits polyfill level";
@@ -415,8 +397,7 @@
 
     /// Polyfill a `firstLeadingBit()` builtin call.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* FirstLeadingBit(ir::CoreBuiltinCall* call) {
+    void FirstLeadingBit(ir::CoreBuiltinCall* call) {
         auto* input = call->Args()[0];
         auto* result_ty = input->Type();
         auto* uint_ty = MatchWidth(ty.u32(), result_ty);
@@ -425,7 +406,6 @@
         // Make an u32 constant with the same component count as result_ty.
         auto V = [&](uint32_t u) { return MatchWidth(b.Constant(u32(u)), result_ty); };
 
-        Value* result = nullptr;
         b.InsertBefore(call, [&] {
             // %x = %input;
             // if (%x is signed) {
@@ -465,22 +445,21 @@
             x = b.ShiftRight(uint_ty, x, b2)->Result(0);
             auto* b1 = b.Call(uint_ty, core::BuiltinFn::kSelect, V(1), V(0),
                               b.Equal(bool_ty, b.And(uint_ty, x, V(0x00000002)), V(0)));
-            result = b.Or(uint_ty, b16, b.Or(uint_ty, b8, b.Or(uint_ty, b4, b.Or(uint_ty, b2, b1))))
-                         ->Result(0);
+            Instruction* result =
+                b.Or(uint_ty, b16, b.Or(uint_ty, b8, b.Or(uint_ty, b4, b.Or(uint_ty, b2, b1))));
             result = b.Call(uint_ty, core::BuiltinFn::kSelect, result, V(0xffffffff),
-                            b.Equal(bool_ty, x, V(0)))
-                         ->Result(0);
+                            b.Equal(bool_ty, x, V(0)));
             if (result_ty->is_signed_integer_scalar_or_vector()) {
-                result = b.Bitcast(result_ty, result)->Result(0);
+                result = b.Bitcast(result_ty, result);
             }
+            result->SetResults(Vector{call->DetachResult()});
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `firstTrailingBit()` builtin call.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* FirstTrailingBit(ir::CoreBuiltinCall* call) {
+    void FirstTrailingBit(ir::CoreBuiltinCall* call) {
         auto* input = call->Args()[0];
         auto* result_ty = input->Type();
         auto* uint_ty = MatchWidth(ty.u32(), result_ty);
@@ -489,7 +468,6 @@
         // Make an u32 constant with the same component count as result_ty.
         auto V = [&](uint32_t u) { return MatchWidth(b.Constant(u32(u)), result_ty); };
 
-        Value* result = nullptr;
         b.InsertBefore(call, [&] {
             // %x = %input;
             // if (%x is signed) {
@@ -525,22 +503,21 @@
             x = b.ShiftRight(uint_ty, x, b2)->Result(0);
             auto* b1 = b.Call(uint_ty, core::BuiltinFn::kSelect, V(0), V(1),
                               b.Equal(bool_ty, b.And(uint_ty, x, V(0x00000001)), V(0)));
-            result = b.Or(uint_ty, b16, b.Or(uint_ty, b8, b.Or(uint_ty, b4, b.Or(uint_ty, b2, b1))))
-                         ->Result(0);
+            Instruction* result =
+                b.Or(uint_ty, b16, b.Or(uint_ty, b8, b.Or(uint_ty, b4, b.Or(uint_ty, b2, b1))));
             result = b.Call(uint_ty, core::BuiltinFn::kSelect, result, V(0xffffffff),
-                            b.Equal(bool_ty, x, V(0)))
-                         ->Result(0);
+                            b.Equal(bool_ty, x, V(0)));
             if (result_ty->is_signed_integer_scalar_or_vector()) {
-                result = b.Bitcast(result_ty, result)->Result(0);
+                result = b.Bitcast(result_ty, result);
             }
+            result->SetResults(Vector{call->DetachResult()});
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill an `insertBits()` builtin call.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* InsertBits(ir::CoreBuiltinCall* call) {
+    void InsertBits(ir::CoreBuiltinCall* call) {
         auto* offset = call->Args()[2];
         auto* count = call->Args()[3];
 
@@ -559,7 +536,7 @@
                     call->SetOperand(ir::CoreBuiltinCall::kArgsOperandOffset + 2, o->Result(0));
                     call->SetOperand(ir::CoreBuiltinCall::kArgsOperandOffset + 3, c->Result(0));
                 });
-                return call->Result(0);
+                break;
             }
             default:
                 TINT_UNIMPLEMENTED() << "insertBits polyfill level";
@@ -568,8 +545,7 @@
 
     /// Polyfill a `saturate()` builtin call.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Saturate(ir::CoreBuiltinCall* call) {
+    void Saturate(ir::CoreBuiltinCall* call) {
         // Replace `saturate(x)` with `clamp(x, 0., 1.)`.
         auto* type = call->Result(0)->Type();
         ir::Constant* zero = nullptr;
@@ -581,21 +557,20 @@
             zero = MatchWidth(b.Constant(0_h), type);
             one = MatchWidth(b.Constant(1_h), type);
         }
-        auto* clamp = b.Call(type, core::BuiltinFn::kClamp, Vector{call->Args()[0], zero, one});
+        auto* clamp = b.CallWithResult(call->DetachResult(), core::BuiltinFn::kClamp,
+                                       Vector{call->Args()[0], zero, one});
         clamp->InsertBefore(call);
-        return clamp->Result(0);
+        call->Destroy();
     }
 
     /// Polyfill a `textureSampleBaseClampToEdge()` builtin call for 2D F32 textures.
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* TextureSampleBaseClampToEdge_2d_f32(ir::CoreBuiltinCall* call) {
+    void TextureSampleBaseClampToEdge_2d_f32(ir::CoreBuiltinCall* call) {
         // Replace `textureSampleBaseClampToEdge(%texture, %sample, %coords)` with:
         //   %dims       = vec2f(textureDimensions(%texture));
         //   %half_texel = vec2f(0.5) / dims;
         //   %clamped    = clamp(%coord, %half_texel, 1.0 - %half_texel);
         //   %result     = textureSampleLevel(%texture, %sampler, %clamped, 0);
-        ir::Value* result = nullptr;
         auto* texture = call->Args()[0];
         auto* sampler = call->Args()[1];
         auto* coords = call->Args()[2];
@@ -607,17 +582,15 @@
             auto* one_minus_half_texel = b.Subtract(vec2f, b.Splat(vec2f, 1_f, 2), half_texel);
             auto* clamped =
                 b.Call(vec2f, core::BuiltinFn::kClamp, coords, half_texel, one_minus_half_texel);
-            result = b.Call(ty.vec4<f32>(), core::BuiltinFn::kTextureSampleLevel, texture, sampler,
-                            clamped, 0_f)
-                         ->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kTextureSampleLevel, texture,
+                             sampler, clamped, 0_f);
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `dot4I8Packed()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Dot4I8Packed(ir::CoreBuiltinCall* call) {
+    void Dot4I8Packed(ir::CoreBuiltinCall* call) {
         // Replace `dot4I8Packed(%x,%y)` with:
         //   %unpacked_x = unpack4xI8(%x);
         //   %unpacked_y = unpack4xI8(%y);
@@ -626,17 +599,15 @@
         auto* y = call->Args()[1];
         auto* unpacked_x = Unpack4xI8OnValue(call, x);
         auto* unpacked_y = Unpack4xI8OnValue(call, y);
-        ir::Value* result = nullptr;
         b.InsertBefore(call, [&] {
-            result = b.Call(ty.i32(), core::BuiltinFn::kDot, unpacked_x, unpacked_y)->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kDot, unpacked_x, unpacked_y);
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `dot4U8Packed()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Dot4U8Packed(ir::CoreBuiltinCall* call) {
+    void Dot4U8Packed(ir::CoreBuiltinCall* call) {
         // Replace `dot4U8Packed(%x,%y)` with:
         //   %unpacked_x = unpack4xU8(%x);
         //   %unpacked_y = unpack4xU8(%y);
@@ -645,23 +616,20 @@
         auto* y = call->Args()[1];
         auto* unpacked_x = Unpack4xU8OnValue(call, x);
         auto* unpacked_y = Unpack4xU8OnValue(call, y);
-        ir::Value* result = nullptr;
         b.InsertBefore(call, [&] {
-            result = b.Call(ty.u32(), core::BuiltinFn::kDot, unpacked_x, unpacked_y)->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kDot, unpacked_x, unpacked_y);
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `pack4xI8()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Pack4xI8(ir::CoreBuiltinCall* call) {
+    void Pack4xI8(ir::CoreBuiltinCall* call) {
         // Replace `pack4xI8(%x)` with:
         //   %n      = vec4u(0, 8, 16, 24);
         //   %x_u32  = bitcast<vec4u>(%x)
         //   %x_u8   = (%x_u32 & vec4u(0xff)) << n;
         //   %result = dot(%x_u8, vec4u(1));
-        ir::Value* result = nullptr;
         auto* x = call->Args()[0];
         b.InsertBefore(call, [&] {
             auto* vec4u = ty.vec4<u32>();
@@ -671,22 +639,19 @@
             auto* x_u32 = b.Bitcast(vec4u, x);
             auto* x_u8 = b.ShiftLeft(
                 vec4u, b.And(vec4u, x_u32, b.Construct(vec4u, b.Constant(u32(0xff)))), n);
-            result = b.Call(ty.u32(), core::BuiltinFn::kDot, x_u8,
-                            b.Construct(vec4u, (b.Constant(u32(1)))))
-                         ->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kDot, x_u8,
+                             b.Construct(vec4u, (b.Constant(u32(1)))));
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `pack4xU8()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Pack4xU8(ir::CoreBuiltinCall* call) {
+    void Pack4xU8(ir::CoreBuiltinCall* call) {
         // Replace `pack4xU8(%x)` with:
         //   %n      = vec4u(0, 8, 16, 24);
         //   %x_i8   = (%x & vec4u(0xff)) << %n;
         //   %result = dot(%x_i8, vec4u(1));
-        ir::Value* result = nullptr;
         auto* x = call->Args()[0];
         b.InsertBefore(call, [&] {
             auto* vec4u = ty.vec4<u32>();
@@ -695,17 +660,15 @@
                                   b.Constant(u32(16)), b.Constant(u32(24)));
             auto* x_u8 =
                 b.ShiftLeft(vec4u, b.And(vec4u, x, b.Construct(vec4u, b.Constant(u32(0xff)))), n);
-            result = b.Call(ty.u32(), core::BuiltinFn::kDot, x_u8,
-                            b.Construct(vec4u, (b.Constant(u32(1)))))
-                         ->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kDot, x_u8,
+                             b.Construct(vec4u, (b.Constant(u32(1)))));
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `pack4xI8Clamp()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Pack4xI8Clamp(ir::CoreBuiltinCall* call) {
+    void Pack4xI8Clamp(ir::CoreBuiltinCall* call) {
         // Replace `pack4xI8Clamp(%x)` with:
         //   %n           = vec4u(0, 8, 16, 24);
         //   %min_i8_vec4 = vec4i(-128);
@@ -714,7 +677,6 @@
         //   %x_u32       = bitcast<vec4u>(%x_clamp);
         //   %x_u8        = (%x_u32 & vec4u(0xff)) << n;
         //   %result      = dot(%x_u8, vec4u(1));
-        ir::Value* result = nullptr;
         auto* x = call->Args()[0];
         b.InsertBefore(call, [&] {
             auto* vec4i = ty.vec4<i32>();
@@ -728,17 +690,15 @@
             auto* x_u32 = b.Bitcast(vec4u, x_clamp);
             auto* x_u8 = b.ShiftLeft(
                 vec4u, b.And(vec4u, x_u32, b.Construct(vec4u, b.Constant(u32(0xff)))), n);
-            result = b.Call(ty.u32(), core::BuiltinFn::kDot, x_u8,
-                            b.Construct(vec4u, (b.Constant(u32(1)))))
-                         ->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kDot, x_u8,
+                             b.Construct(vec4u, (b.Constant(u32(1)))));
         });
-        return result;
+        call->Destroy();
     }
 
     /// Polyfill a `pack4xU8Clamp()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Pack4xU8Clamp(ir::CoreBuiltinCall* call) {
+    void Pack4xU8Clamp(ir::CoreBuiltinCall* call) {
         // Replace `pack4xU8Clamp(%x)` with:
         //   %n       = vec4u(0, 8, 16, 24);
         //   %min_u8_vec4 = vec4u(0);
@@ -746,7 +706,6 @@
         //   %x_clamp = clamp(%x, vec4u(0), vec4u(255));
         //   %x_u8    = %x_clamp << n;
         //   %result  = dot(%x_u8, vec4u(1));
-        ir::Value* result = nullptr;
         auto* x = call->Args()[0];
         b.InsertBefore(call, [&] {
             auto* vec4u = ty.vec4<u32>();
@@ -757,23 +716,22 @@
             auto* max_u8_vec4 = b.Construct(vec4u, b.Constant(u32(255)));
             auto* x_clamp = b.Call(vec4u, core::BuiltinFn::kClamp, x, min_u8_vec4, max_u8_vec4);
             auto* x_u8 = b.ShiftLeft(vec4u, x_clamp, n);
-            result = b.Call(ty.u32(), core::BuiltinFn::kDot, x_u8,
-                            b.Construct(vec4u, (b.Constant(u32(1)))))
-                         ->Result(0);
+            b.CallWithResult(call->DetachResult(), core::BuiltinFn::kDot, x_u8,
+                             b.Construct(vec4u, (b.Constant(u32(1)))));
         });
-        return result;
+        call->Destroy();
     }
 
     /// Emit code for `unpack4xI8` on u32 value `x`, before the given call.
     /// @param call the instruction that should follow the emitted code
     /// @param x the u32 value to be unpacked
-    ir::Value* Unpack4xI8OnValue(ir::CoreBuiltinCall* call, ir::Value* x) {
+    ir::Instruction* Unpack4xI8OnValue(ir::CoreBuiltinCall* call, ir::Value* x) {
         // Replace `unpack4xI8(%x)` with:
         //   %n       = vec4u(24, 16, 8, 0);
         //   %x_splat = vec4u(%x); // splat the scalar to a vector
         //   %x_vec4i = bitcast<vec4i>(%x_splat << n);
         //   %result  = %x_vec4i >> vec4u(24);
-        ir::Value* result = nullptr;
+        ir::Instruction* result = nullptr;
         b.InsertBefore(call, [&] {
             auto* vec4i = ty.vec4<i32>();
             auto* vec4u = ty.vec4<u32>();
@@ -782,29 +740,29 @@
                                   b.Constant(u32(8)), b.Constant(u32(0)));
             auto* x_splat = b.Construct(vec4u, x);
             auto* x_vec4i = b.Bitcast(vec4i, b.ShiftLeft(vec4u, x_splat, n));
-            result =
-                b.ShiftRight(vec4i, x_vec4i, b.Construct(vec4u, b.Constant(u32(24))))->Result(0);
+            result = b.ShiftRight(vec4i, x_vec4i, b.Construct(vec4u, b.Constant(u32(24))));
         });
         return result;
     }
 
     /// Polyfill a `unpack4xI8()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Unpack4xI8(ir::CoreBuiltinCall* call) {
-        return Unpack4xI8OnValue(call, call->Args()[0]);
+    void Unpack4xI8(ir::CoreBuiltinCall* call) {
+        auto* result = Unpack4xI8OnValue(call, call->Args()[0]);
+        result->SetResults(Vector{call->DetachResult()});
+        call->Destroy();
     }
 
     /// Emit code for `unpack4xU8` on u32 value `x`, before the given call.
     /// @param call the instruction that should follow the emitted code
     /// @param x the u32 value to be unpacked
-    ir::Value* Unpack4xU8OnValue(ir::CoreBuiltinCall* call, ir::Value* x) {
+    Instruction* Unpack4xU8OnValue(ir::CoreBuiltinCall* call, ir::Value* x) {
         // Replace `unpack4xU8(%x)` with:
         //   %n       = vec4u(0, 8, 16, 24);
         //   %x_splat = vec4u(%x); // splat the scalar to a vector
         //   %x_vec4u = %x_splat >> n;
         //   %result  = %x_vec4u & vec4u(0xff);
-        ir::Value* result = nullptr;
+        ir::Instruction* result = nullptr;
         b.InsertBefore(call, [&] {
             auto* vec4u = ty.vec4<u32>();
 
@@ -812,16 +770,17 @@
                                   b.Constant(u32(16)), b.Constant(u32(24)));
             auto* x_splat = b.Construct(vec4u, x);
             auto* x_vec4u = b.ShiftRight(vec4u, x_splat, n);
-            result = b.And(vec4u, x_vec4u, b.Construct(vec4u, b.Constant(u32(0xff))))->Result(0);
+            result = b.And(vec4u, x_vec4u, b.Construct(vec4u, b.Constant(u32(0xff))));
         });
         return result;
     }
 
     /// Polyfill a `unpack4xU8()` builtin call
     /// @param call the builtin call instruction
-    /// @returns the replacement value
-    ir::Value* Unpack4xU8(ir::CoreBuiltinCall* call) {
-        return Unpack4xU8OnValue(call, call->Args()[0]);
+    void Unpack4xU8(ir::CoreBuiltinCall* call) {
+        auto* result = Unpack4xU8OnValue(call, call->Args()[0]);
+        result->SetResults(Vector{call->DetachResult()});
+        call->Destroy();
     }
 };
 
diff --git a/src/tint/lang/core/ir/transform/common/BUILD.bazel b/src/tint/lang/core/ir/transform/common/BUILD.bazel
new file mode 100644
index 0000000..f05642f
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/common/BUILD.bazel
@@ -0,0 +1,103 @@
+# Copyright 2024 The Dawn & Tint Authors
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice, this
+#    list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+################################################################################
+# File generated by 'tools/src/cmd/gen' using the template:
+#   tools/src/cmd/gen/build/BUILD.bazel.tmpl
+#
+# To regenerate run: './tools/run gen'
+#
+#                       Do not modify this file directly
+################################################################################
+
+load("//src/tint:flags.bzl", "COPTS")
+load("@bazel_skylib//lib:selects.bzl", "selects")
+cc_library(
+  name = "common",
+  srcs = [
+    "referenced_module_vars.cc",
+  ],
+  hdrs = [
+    "referenced_module_vars.h",
+  ],
+  deps = [
+    "//src/tint/api/common",
+    "//src/tint/lang/core",
+    "//src/tint/lang/core/constant",
+    "//src/tint/lang/core/ir",
+    "//src/tint/lang/core/type",
+    "//src/tint/utils/containers",
+    "//src/tint/utils/diagnostic",
+    "//src/tint/utils/ice",
+    "//src/tint/utils/id",
+    "//src/tint/utils/macros",
+    "//src/tint/utils/math",
+    "//src/tint/utils/memory",
+    "//src/tint/utils/reflection",
+    "//src/tint/utils/result",
+    "//src/tint/utils/rtti",
+    "//src/tint/utils/symbol",
+    "//src/tint/utils/text",
+    "//src/tint/utils/traits",
+  ],
+  copts = COPTS,
+  visibility = ["//visibility:public"],
+)
+cc_library(
+  name = "test",
+  alwayslink = True,
+  srcs = [
+    "referenced_module_vars_test.cc",
+  ],
+  deps = [
+    "//src/tint/api/common",
+    "//src/tint/lang/core",
+    "//src/tint/lang/core/constant",
+    "//src/tint/lang/core/intrinsic",
+    "//src/tint/lang/core/ir",
+    "//src/tint/lang/core/ir/transform/common",
+    "//src/tint/lang/core/ir:test",
+    "//src/tint/lang/core/type",
+    "//src/tint/utils/containers",
+    "//src/tint/utils/diagnostic",
+    "//src/tint/utils/ice",
+    "//src/tint/utils/id",
+    "//src/tint/utils/macros",
+    "//src/tint/utils/math",
+    "//src/tint/utils/memory",
+    "//src/tint/utils/reflection",
+    "//src/tint/utils/result",
+    "//src/tint/utils/rtti",
+    "//src/tint/utils/symbol",
+    "//src/tint/utils/text",
+    "//src/tint/utils/traits",
+    "@gtest",
+  ],
+  copts = COPTS,
+  visibility = ["//visibility:public"],
+)
+
diff --git a/src/tint/lang/core/ir/transform/common/BUILD.cmake b/src/tint/lang/core/ir/transform/common/BUILD.cmake
new file mode 100644
index 0000000..7004d86
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/common/BUILD.cmake
@@ -0,0 +1,101 @@
+# Copyright 2024 The Dawn & Tint Authors
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice, this
+#    list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+################################################################################
+# File generated by 'tools/src/cmd/gen' using the template:
+#   tools/src/cmd/gen/build/BUILD.cmake.tmpl
+#
+# To regenerate run: './tools/run gen'
+#
+#                       Do not modify this file directly
+################################################################################
+
+################################################################################
+# Target:    tint_lang_core_ir_transform_common
+# Kind:      lib
+################################################################################
+tint_add_target(tint_lang_core_ir_transform_common lib
+  lang/core/ir/transform/common/referenced_module_vars.cc
+  lang/core/ir/transform/common/referenced_module_vars.h
+)
+
+tint_target_add_dependencies(tint_lang_core_ir_transform_common lib
+  tint_api_common
+  tint_lang_core
+  tint_lang_core_constant
+  tint_lang_core_ir
+  tint_lang_core_type
+  tint_utils_containers
+  tint_utils_diagnostic
+  tint_utils_ice
+  tint_utils_id
+  tint_utils_macros
+  tint_utils_math
+  tint_utils_memory
+  tint_utils_reflection
+  tint_utils_result
+  tint_utils_rtti
+  tint_utils_symbol
+  tint_utils_text
+  tint_utils_traits
+)
+
+################################################################################
+# Target:    tint_lang_core_ir_transform_common_test
+# Kind:      test
+################################################################################
+tint_add_target(tint_lang_core_ir_transform_common_test test
+  lang/core/ir/transform/common/referenced_module_vars_test.cc
+)
+
+tint_target_add_dependencies(tint_lang_core_ir_transform_common_test test
+  tint_api_common
+  tint_lang_core
+  tint_lang_core_constant
+  tint_lang_core_intrinsic
+  tint_lang_core_ir
+  tint_lang_core_ir_transform_common
+  tint_lang_core_ir_test
+  tint_lang_core_type
+  tint_utils_containers
+  tint_utils_diagnostic
+  tint_utils_ice
+  tint_utils_id
+  tint_utils_macros
+  tint_utils_math
+  tint_utils_memory
+  tint_utils_reflection
+  tint_utils_result
+  tint_utils_rtti
+  tint_utils_symbol
+  tint_utils_text
+  tint_utils_traits
+)
+
+tint_target_add_external_dependencies(tint_lang_core_ir_transform_common_test test
+  "gtest"
+)
diff --git a/src/tint/lang/core/ir/transform/common/BUILD.gn b/src/tint/lang/core/ir/transform/common/BUILD.gn
new file mode 100644
index 0000000..d050caf
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/common/BUILD.gn
@@ -0,0 +1,99 @@
+# Copyright 2024 The Dawn & Tint Authors
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice, this
+#    list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+################################################################################
+# File generated by 'tools/src/cmd/gen' using the template:
+#   tools/src/cmd/gen/build/BUILD.gn.tmpl
+#
+# To regenerate run: './tools/run gen'
+#
+#                       Do not modify this file directly
+################################################################################
+
+import("../../../../../../../scripts/tint_overrides_with_defaults.gni")
+
+import("${tint_src_dir}/tint.gni")
+
+if (tint_build_unittests || tint_build_benchmarks) {
+  import("//testing/test.gni")
+}
+
+libtint_source_set("common") {
+  sources = [
+    "referenced_module_vars.cc",
+    "referenced_module_vars.h",
+  ]
+  deps = [
+    "${tint_src_dir}/api/common",
+    "${tint_src_dir}/lang/core",
+    "${tint_src_dir}/lang/core/constant",
+    "${tint_src_dir}/lang/core/ir",
+    "${tint_src_dir}/lang/core/type",
+    "${tint_src_dir}/utils/containers",
+    "${tint_src_dir}/utils/diagnostic",
+    "${tint_src_dir}/utils/ice",
+    "${tint_src_dir}/utils/id",
+    "${tint_src_dir}/utils/macros",
+    "${tint_src_dir}/utils/math",
+    "${tint_src_dir}/utils/memory",
+    "${tint_src_dir}/utils/reflection",
+    "${tint_src_dir}/utils/result",
+    "${tint_src_dir}/utils/rtti",
+    "${tint_src_dir}/utils/symbol",
+    "${tint_src_dir}/utils/text",
+    "${tint_src_dir}/utils/traits",
+  ]
+}
+if (tint_build_unittests) {
+  tint_unittests_source_set("unittests") {
+    sources = [ "referenced_module_vars_test.cc" ]
+    deps = [
+      "${tint_src_dir}:gmock_and_gtest",
+      "${tint_src_dir}/api/common",
+      "${tint_src_dir}/lang/core",
+      "${tint_src_dir}/lang/core/constant",
+      "${tint_src_dir}/lang/core/intrinsic",
+      "${tint_src_dir}/lang/core/ir",
+      "${tint_src_dir}/lang/core/ir:unittests",
+      "${tint_src_dir}/lang/core/ir/transform/common",
+      "${tint_src_dir}/lang/core/type",
+      "${tint_src_dir}/utils/containers",
+      "${tint_src_dir}/utils/diagnostic",
+      "${tint_src_dir}/utils/ice",
+      "${tint_src_dir}/utils/id",
+      "${tint_src_dir}/utils/macros",
+      "${tint_src_dir}/utils/math",
+      "${tint_src_dir}/utils/memory",
+      "${tint_src_dir}/utils/reflection",
+      "${tint_src_dir}/utils/result",
+      "${tint_src_dir}/utils/rtti",
+      "${tint_src_dir}/utils/symbol",
+      "${tint_src_dir}/utils/text",
+      "${tint_src_dir}/utils/traits",
+    ]
+  }
+}
diff --git a/src/tint/lang/core/ir/transform/common/referenced_module_vars.cc b/src/tint/lang/core/ir/transform/common/referenced_module_vars.cc
new file mode 100644
index 0000000..b97b9dd
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/common/referenced_module_vars.cc
@@ -0,0 +1,75 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/core/ir/transform/common/referenced_module_vars.h"
+
+#include "src/tint/lang/core/ir/control_instruction.h"
+#include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/user_call.h"
+#include "src/tint/lang/core/ir/var.h"
+#include "src/tint/utils/rtti/switch.h"
+
+namespace tint::core::ir {
+
+const ReferencedModuleVars::VarSet& ReferencedModuleVars::TransitiveReferences(Function* func) {
+    return transitive_references_.GetOrAdd(func, [&] {
+        VarSet vars;
+        GetTransitiveReferences(func->Block(), vars);
+        return vars;
+    });
+}
+
+/// Get the set of variables transitively referenced by @p block.
+/// @param block the block
+/// @param vars the set of transitively referenced variables to populate
+void ReferencedModuleVars::GetTransitiveReferences(Block* block, VarSet& vars) {
+    // Add directly referenced vars.
+    if (auto itr = block_to_direct_vars_.Get(block)) {
+        for (auto& var : *itr) {
+            vars.Add(var);
+        }
+    }
+
+    // Loop over instructions in the block to find indirectly referenced vars.
+    for (auto* inst : *block) {
+        tint::Switch(
+            inst,
+            [&](UserCall* call) {
+                // Get variables referenced by a function called from this block.
+                const auto& callee_vars = TransitiveReferences(call->Target());
+                for (auto* var : callee_vars) {
+                    vars.Add(var);
+                }
+            },
+            [&](ControlInstruction* ctrl) {
+                // Recurse into control instructions and gather their referenced vars.
+                ctrl->ForeachBlock([&](Block* blk) { GetTransitiveReferences(blk, vars); });
+            });
+    }
+}
+
+}  // namespace tint::core::ir
diff --git a/src/tint/lang/core/ir/transform/common/referenced_module_vars.h b/src/tint/lang/core/ir/transform/common/referenced_module_vars.h
new file mode 100644
index 0000000..a48b0a7
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/common/referenced_module_vars.h
@@ -0,0 +1,105 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#ifndef SRC_TINT_LANG_CORE_IR_TRANSFORM_COMMON_REFERENCED_MODULE_VARS_H_
+#define SRC_TINT_LANG_CORE_IR_TRANSFORM_COMMON_REFERENCED_MODULE_VARS_H_
+
+#include <functional>
+
+#include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/var.h"
+#include "src/tint/utils/containers/hashmap.h"
+#include "src/tint/utils/containers/unique_vector.h"
+
+// Forward declarations.
+namespace tint::core::ir {
+class Block;
+class Function;
+}  // namespace tint::core::ir
+
+namespace tint::core::ir {
+
+/// ReferencedModuleVars is a helper to determine the set of module-scope variables that are
+/// transitively referenced by functions in a module.
+/// References are determined lazily and cached for future requests.
+///
+/// Note: changes to the module can invalidate the cached data. This is intended to be created by
+/// a transform that need this information, and discarded when that transform completes. Tracking
+/// this information inside the IR module would add overhead any time an instruction is added or
+/// removed from the module. Since only a few transforms need this information, we expect it to be
+/// more efficient to generate it as and when needed instead.
+class ReferencedModuleVars {
+  public:
+    /// The signature of a predicate used to filter variables.
+    /// A predicate function should return `true` when the variable should be added to the set.
+    using Predicate = std::function<bool(const Var*)>;
+
+    /// A set of a variables referenced by a function (in declaration order).
+    using VarSet = UniqueVector<Var*, 16>;
+
+    /// Constructor.
+    /// @param ir the module
+    /// @param pred an optional predicate function for filtering variables
+    /// Note: @p pred is not stored by the class, so can be a lambda that captures by reference.
+    explicit ReferencedModuleVars(Module& ir, Predicate&& pred = {}) : ir_(ir) {
+        // Loop over module-scope variables, recording the blocks that they are referenced from.
+        for (auto inst : *ir_.root_block) {
+            if (auto* var = inst->As<Var>()) {
+                if (!pred || pred(var)) {
+                    var->Result(0)->ForEachUse([&](const Usage& use) {
+                        block_to_direct_vars_.GetOrAddZero(use.instruction->Block()).Add(var);
+                    });
+                }
+            }
+        }
+    }
+
+    /// Get the set of transitively referenced module-scope variables for a function, filtered by
+    /// the predicate function if provided.
+    /// @param func the function
+    /// @returns the set of (possibly filtered) transitively reference module-scope variables
+    const VarSet& TransitiveReferences(Function* func);
+
+  private:
+    /// The module.
+    Module& ir_;
+
+    /// A map from blocks to their directly referenced variables.
+    Hashmap<Block*, VarSet, 64> block_to_direct_vars_{};
+
+    /// A map from functions to their transitively referenced variables.
+    Hashmap<Function*, VarSet, 8> transitive_references_;
+
+    /// Get the set of transitively referenced module-scope variables for a block.
+    /// @param block the block
+    /// @param vars the set of transitively reference module-scope variables to populate
+    void GetTransitiveReferences(Block* block, VarSet& vars);
+};
+
+}  // namespace tint::core::ir
+
+#endif  // SRC_TINT_LANG_CORE_IR_TRANSFORM_COMMON_REFERENCED_MODULE_VARS_H_
diff --git a/src/tint/lang/core/ir/transform/common/referenced_module_vars_test.cc b/src/tint/lang/core/ir/transform/common/referenced_module_vars_test.cc
new file mode 100644
index 0000000..3a5a032
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/common/referenced_module_vars_test.cc
@@ -0,0 +1,422 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/core/ir/transform/common/referenced_module_vars.h"
+
+#include <string>
+
+#include "gmock/gmock.h"
+#include "src/tint/lang/core/ir/disassembly.h"
+#include "src/tint/lang/core/ir/ir_helper_test.h"
+
+namespace tint::core::ir {
+namespace {
+
+using ::testing::ElementsAre;
+
+using namespace tint::core::fluent_types;     // NOLINT
+using namespace tint::core::number_suffixes;  // NOLINT
+
+class IR_ReferencedModuleVarsTest : public IRTestHelper {
+  protected:
+    /// @returns the module as a disassembled string
+    std::string Disassemble() const { return "\n" + ir::Disassemble(mod).Plain(); }
+};
+
+TEST_F(IR_ReferencedModuleVarsTest, EmptyRootBlock) {
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+%foo = func():void {
+  $B1: {
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod);
+    auto& foo_vars = vars.TransitiveReferences(foo);
+    EXPECT_TRUE(foo_vars.IsEmpty());
+}
+
+TEST_F(IR_ReferencedModuleVarsTest, DirectUse) {
+    // Referenced.
+    auto* var_a = mod.root_block->Append(b.Var<workgroup, u32>("a"));
+    auto* var_b = mod.root_block->Append(b.Var<workgroup, u32>("b"));
+    // Not referenced.
+    mod.root_block->Append(b.Var<workgroup, u32>("c"));
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        b.Load(var_a);
+        b.Load(var_b);
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %a:ptr<workgroup, u32, read_write> = var
+  %b:ptr<workgroup, u32, read_write> = var
+  %c:ptr<workgroup, u32, read_write> = var
+}
+
+%foo = func():void {
+  $B2: {
+    %5:u32 = load %a
+    %6:u32 = load %b
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod);
+    EXPECT_THAT(vars.TransitiveReferences(foo), ElementsAre(var_a, var_b));
+}
+
+TEST_F(IR_ReferencedModuleVarsTest, DirectUse_DeclarationOrder) {
+    auto* var_a = mod.root_block->Append(b.Var<workgroup, u32>("a"));
+    auto* var_b = mod.root_block->Append(b.Var<workgroup, u32>("b"));
+    auto* var_c = mod.root_block->Append(b.Var<workgroup, u32>("c"));
+    auto* var_d = mod.root_block->Append(b.Var<workgroup, u32>("d"));
+    auto* var_e = mod.root_block->Append(b.Var<workgroup, u32>("e"));
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        b.Load(var_b);
+        b.Load(var_e);
+        b.Load(var_d);
+        b.Load(var_c);
+        b.Load(var_a);
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %a:ptr<workgroup, u32, read_write> = var
+  %b:ptr<workgroup, u32, read_write> = var
+  %c:ptr<workgroup, u32, read_write> = var
+  %d:ptr<workgroup, u32, read_write> = var
+  %e:ptr<workgroup, u32, read_write> = var
+}
+
+%foo = func():void {
+  $B2: {
+    %7:u32 = load %b
+    %8:u32 = load %e
+    %9:u32 = load %d
+    %10:u32 = load %c
+    %11:u32 = load %a
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod);
+    EXPECT_THAT(vars.TransitiveReferences(foo), ElementsAre(var_a, var_b, var_c, var_d, var_e));
+}
+
+TEST_F(IR_ReferencedModuleVarsTest, DirectUse_MultipleFunctions) {
+    auto* var_a = mod.root_block->Append(b.Var<workgroup, u32>("a"));
+    auto* var_b = mod.root_block->Append(b.Var<workgroup, u32>("b"));
+    auto* var_c = mod.root_block->Append(b.Var<workgroup, u32>("c"));
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        b.Load(var_a);
+        b.Load(var_b);
+        b.Return(foo);
+    });
+
+    auto* bar = b.Function("bar", ty.void_());
+    b.Append(bar->Block(), [&] {  //
+        b.Load(var_a);
+        b.Load(var_c);
+        b.Return(bar);
+    });
+
+    auto* zoo = b.Function("zoo", ty.void_());
+    b.Append(zoo->Block(), [&] {  //
+        b.Return(zoo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %a:ptr<workgroup, u32, read_write> = var
+  %b:ptr<workgroup, u32, read_write> = var
+  %c:ptr<workgroup, u32, read_write> = var
+}
+
+%foo = func():void {
+  $B2: {
+    %5:u32 = load %a
+    %6:u32 = load %b
+    ret
+  }
+}
+%bar = func():void {
+  $B3: {
+    %8:u32 = load %a
+    %9:u32 = load %c
+    ret
+  }
+}
+%zoo = func():void {
+  $B4: {
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod);
+    EXPECT_THAT(vars.TransitiveReferences(foo), ElementsAre(var_a, var_b));
+    EXPECT_THAT(vars.TransitiveReferences(bar), ElementsAre(var_a, var_c));
+    EXPECT_TRUE(vars.TransitiveReferences(zoo).IsEmpty());
+}
+
+TEST_F(IR_ReferencedModuleVarsTest, DirectUse_NestedInControlFlow) {
+    auto* var_a = mod.root_block->Append(b.Var<workgroup, u32>("a"));
+    auto* var_b = mod.root_block->Append(b.Var<workgroup, u32>("b"));
+    auto* var_c = mod.root_block->Append(b.Var<workgroup, u32>("c"));
+    auto* var_d = mod.root_block->Append(b.Var<workgroup, u32>("c"));
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        auto* ifelse = b.If(true);
+        b.Append(ifelse->True(), [&] {
+            b.Load(var_a);
+            b.ExitIf(ifelse);
+        });
+        b.Append(ifelse->False(), [&] {
+            auto* loop = b.Loop();
+            b.Append(loop->Initializer(), [&] {
+                b.Load(var_b);
+                b.NextIteration(loop);
+            });
+            b.Append(loop->Body(), [&] {
+                b.Load(var_c);
+                b.Continue(loop);
+            });
+            b.Append(loop->Continuing(), [&] {
+                b.Load(var_d);
+                b.NextIteration(loop);
+            });
+            b.ExitIf(ifelse);
+        });
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %a:ptr<workgroup, u32, read_write> = var
+  %b:ptr<workgroup, u32, read_write> = var
+  %c:ptr<workgroup, u32, read_write> = var
+  %c_1:ptr<workgroup, u32, read_write> = var  # %c_1: 'c'
+}
+
+%foo = func():void {
+  $B2: {
+    if true [t: $B3, f: $B4] {  # if_1
+      $B3: {  # true
+        %6:u32 = load %a
+        exit_if  # if_1
+      }
+      $B4: {  # false
+        loop [i: $B5, b: $B6, c: $B7] {  # loop_1
+          $B5: {  # initializer
+            %7:u32 = load %b
+            next_iteration  # -> $B6
+          }
+          $B6: {  # body
+            %8:u32 = load %c
+            continue  # -> $B7
+          }
+          $B7: {  # continuing
+            %9:u32 = load %c_1
+            next_iteration  # -> $B6
+          }
+        }
+        exit_if  # if_1
+      }
+    }
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod);
+    EXPECT_THAT(vars.TransitiveReferences(foo), ElementsAre(var_a, var_b, var_c, var_d));
+}
+
+TEST_F(IR_ReferencedModuleVarsTest, IndirectUse) {
+    // Directly used by foo.
+    auto* var_a = mod.root_block->Append(b.Var<workgroup, u32>("a"));
+    // Directly used by bar, called by zoo and foo.
+    auto* var_b = mod.root_block->Append(b.Var<workgroup, u32>("b"));
+    // Not used.
+    mod.root_block->Append(b.Var<workgroup, u32>("c"));
+
+    auto* bar = b.Function("bar", ty.void_());
+    b.Append(bar->Block(), [&] {  //
+        b.Load(var_b);
+        b.Return(bar);
+    });
+
+    auto* zoo = b.Function("zoo", ty.void_());
+    b.Append(zoo->Block(), [&] {  //
+        b.Call(bar);
+        b.Return(zoo);
+    });
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        b.Load(var_a);
+        b.Call(zoo);
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %a:ptr<workgroup, u32, read_write> = var
+  %b:ptr<workgroup, u32, read_write> = var
+  %c:ptr<workgroup, u32, read_write> = var
+}
+
+%bar = func():void {
+  $B2: {
+    %5:u32 = load %b
+    ret
+  }
+}
+%zoo = func():void {
+  $B3: {
+    %7:void = call %bar
+    ret
+  }
+}
+%foo = func():void {
+  $B4: {
+    %9:u32 = load %a
+    %10:void = call %zoo
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod);
+    EXPECT_THAT(vars.TransitiveReferences(bar), ElementsAre(var_b));
+    EXPECT_THAT(vars.TransitiveReferences(zoo), ElementsAre(var_b));
+    EXPECT_THAT(vars.TransitiveReferences(foo), ElementsAre(var_a, var_b));
+}
+
+TEST_F(IR_ReferencedModuleVarsTest, NoFunctionVars) {
+    auto* var_a = mod.root_block->Append(b.Var<workgroup, u32>("a"));
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        auto* var_b = b.Var<function, u32>("b");
+        b.Load(var_a);
+        b.Load(var_b);
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %a:ptr<workgroup, u32, read_write> = var
+}
+
+%foo = func():void {
+  $B2: {
+    %b:ptr<function, u32, read_write> = var
+    %4:u32 = load %a
+    %5:u32 = load %b
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod);
+    EXPECT_THAT(vars.TransitiveReferences(foo), ElementsAre(var_a));
+}
+
+TEST_F(IR_ReferencedModuleVarsTest, Predicate) {
+    auto* var_a = mod.root_block->Append(b.Var<workgroup, u32>("a"));
+    auto* var_b = mod.root_block->Append(b.Var<private_, u32>("b"));
+    auto* var_c = mod.root_block->Append(b.Var<workgroup, u32>("c"));
+    auto* var_d = mod.root_block->Append(b.Var<private_, u32>("d"));
+    auto* var_e = mod.root_block->Append(b.Var<workgroup, u32>("e"));
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {  //
+        b.Load(var_a);
+        b.Load(var_b);
+        b.Load(var_c);
+        b.Load(var_d);
+        b.Load(var_e);
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %a:ptr<workgroup, u32, read_write> = var
+  %b:ptr<private, u32, read_write> = var
+  %c:ptr<workgroup, u32, read_write> = var
+  %d:ptr<private, u32, read_write> = var
+  %e:ptr<workgroup, u32, read_write> = var
+}
+
+%foo = func():void {
+  $B2: {
+    %7:u32 = load %a
+    %8:u32 = load %b
+    %9:u32 = load %c
+    %10:u32 = load %d
+    %11:u32 = load %e
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, Disassemble());
+
+    ReferencedModuleVars vars(mod, [](const Var* var) {
+        auto* view = var->Result(0)->Type()->As<type::MemoryView>();
+        return view->AddressSpace() == AddressSpace::kPrivate;
+    });
+    EXPECT_THAT(vars.TransitiveReferences(foo), ElementsAre(var_b, var_d));
+}
+
+}  // namespace
+}  // namespace tint::core::ir
diff --git a/src/tint/lang/core/ir/transform/conversion_polyfill.cc b/src/tint/lang/core/ir/transform/conversion_polyfill.cc
index c5f7bbd..76d1920 100644
--- a/src/tint/lang/core/ir/transform/conversion_polyfill.cc
+++ b/src/tint/lang/core/ir/transform/conversion_polyfill.cc
@@ -82,13 +82,7 @@
 
         // Polyfill the conversion instructions that we found.
         for (auto* convert : ftoi_worklist) {
-            auto* replacement = ftoi(convert);
-
-            // Replace the old conversion instruction result with the new value.
-            if (auto name = ir.NameOf(convert->Result(0))) {
-                ir.SetName(replacement, name);
-            }
-            convert->Result(0)->ReplaceAllUsesWith(replacement);
+            ftoi(convert);
             convert->Destroy();
         }
     }
@@ -96,8 +90,7 @@
     /// Replace a conversion instruction with a call to helper function that manually clamps the
     /// result to within the limit of the destination type.
     /// @param convert the conversion instruction
-    /// @returns the replacement value
-    ir::Value* ftoi(ir::Convert* convert) {
+    void ftoi(ir::Convert* convert) {
         auto* res_ty = convert->Result(0)->Type();
         auto* src_ty = convert->Args()[0]->Type();
         auto* src_el_ty = src_ty->DeepestElement();
@@ -190,9 +183,8 @@
         });
 
         // Call the helper function, splatting the arguments to match the target vector width.
-        auto* call = b.Call(res_ty, helper, convert->Args()[0]);
+        auto* call = b.CallWithResult(convert->DetachResult(), helper, convert->Args()[0]);
         call->InsertBefore(convert);
-        return call->Result(0);
     }
 
     /// Return a type with element type @p type that has the same number of vector components as
diff --git a/src/tint/lang/core/ir/transform/multiplanar_external_texture.cc b/src/tint/lang/core/ir/transform/multiplanar_external_texture.cc
index d63e3ba..aa0d1dc 100644
--- a/src/tint/lang/core/ir/transform/multiplanar_external_texture.cc
+++ b/src/tint/lang/core/ir/transform/multiplanar_external_texture.cc
@@ -240,19 +240,18 @@
                         }
 
                         // Call the `TextureLoadExternal()` helper function.
-                        auto* helper = b.Call(ty.vec4<f32>(), TextureLoadExternal(), plane_0,
-                                              plane_1, params, coords);
+                        auto* helper = b.CallWithResult(call->DetachResult(), TextureLoadExternal(),
+                                                        plane_0, plane_1, params, coords);
                         helper->InsertBefore(call);
-                        call->Result(0)->ReplaceAllUsesWith(helper->Result(0));
                         call->Destroy();
                     } else if (call->Func() == core::BuiltinFn::kTextureSampleBaseClampToEdge) {
                         // Call the `TextureSampleExternal()` helper function.
                         auto* sampler = call->Args()[1];
                         auto* coords = call->Args()[2];
-                        auto* helper = b.Call(ty.vec4<f32>(), TextureSampleExternal(), plane_0,
-                                              plane_1, params, sampler, coords);
+                        auto* helper =
+                            b.CallWithResult(call->DetachResult(), TextureSampleExternal(), plane_0,
+                                             plane_1, params, sampler, coords);
                         helper->InsertBefore(call);
-                        call->Result(0)->ReplaceAllUsesWith(helper->Result(0));
                         call->Destroy();
                     } else {
                         TINT_ICE() << "unhandled texture_external builtin call: " << call->Func();
diff --git a/src/tint/lang/core/ir/transform/multiplanar_external_texture_test.cc b/src/tint/lang/core/ir/transform/multiplanar_external_texture_test.cc
index 6597fb8..f032ba8 100644
--- a/src/tint/lang/core/ir/transform/multiplanar_external_texture_test.cc
+++ b/src/tint/lang/core/ir/transform/multiplanar_external_texture_test.cc
@@ -352,8 +352,8 @@
     %6:texture_2d<f32> = load %texture_plane0
     %7:texture_2d<f32> = load %texture_plane1
     %8:tint_ExternalTextureParams = load %texture_params
-    %9:vec4<f32> = call %tint_TextureLoadExternal, %6, %7, %8, %coords
-    ret %9
+    %result:vec4<f32> = call %tint_TextureLoadExternal, %6, %7, %8, %coords
+    ret %result
   }
 }
 %tint_TextureLoadExternal = func(%plane_0:texture_2d<f32>, %plane_1:texture_2d<f32>, %params:tint_ExternalTextureParams, %coords_1:vec2<u32>):vec4<f32> {  # %coords_1: 'coords'
@@ -514,8 +514,8 @@
     %7:texture_2d<f32> = load %texture_plane1
     %8:tint_ExternalTextureParams = load %texture_params
     %9:vec2<u32> = convert %coords
-    %10:vec4<f32> = call %tint_TextureLoadExternal, %6, %7, %8, %9
-    ret %10
+    %result:vec4<f32> = call %tint_TextureLoadExternal, %6, %7, %8, %9
+    ret %result
   }
 }
 %tint_TextureLoadExternal = func(%plane_0:texture_2d<f32>, %plane_1:texture_2d<f32>, %params:tint_ExternalTextureParams, %coords_1:vec2<u32>):vec4<f32> {  # %coords_1: 'coords'
@@ -677,8 +677,8 @@
     %7:texture_2d<f32> = load %texture_plane0
     %8:texture_2d<f32> = load %texture_plane1
     %9:tint_ExternalTextureParams = load %texture_params
-    %10:vec4<f32> = call %tint_TextureSampleExternal, %7, %8, %9, %sampler, %coords
-    ret %10
+    %result:vec4<f32> = call %tint_TextureSampleExternal, %7, %8, %9, %sampler, %coords
+    ret %result
   }
 }
 %tint_TextureSampleExternal = func(%plane_0:texture_2d<f32>, %plane_1:texture_2d<f32>, %params:tint_ExternalTextureParams, %sampler_1:sampler, %coords_1:vec2<f32>):vec4<f32> {  # %sampler_1: 'sampler', %coords_1: 'coords'
@@ -856,8 +856,8 @@
 
 %foo = func(%texture_plane0_1:texture_2d<f32>, %texture_plane1_1:texture_2d<f32>, %texture_params_1:tint_ExternalTextureParams, %sampler:sampler, %coords:vec2<f32>):vec4<f32> {  # %texture_plane0_1: 'texture_plane0', %texture_plane1_1: 'texture_plane1', %texture_params_1: 'texture_params'
   $B2: {
-    %10:vec4<f32> = call %tint_TextureSampleExternal, %texture_plane0_1, %texture_plane1_1, %texture_params_1, %sampler, %coords
-    ret %10
+    %result:vec4<f32> = call %tint_TextureSampleExternal, %texture_plane0_1, %texture_plane1_1, %texture_params_1, %sampler, %coords
+    ret %result
   }
 }
 %bar = func(%sampler_1:sampler, %coords_1:vec2<f32>):vec4<f32> {  # %sampler_1: 'sampler', %coords_1: 'coords'
@@ -865,8 +865,8 @@
     %15:texture_2d<f32> = load %texture_plane0
     %16:texture_2d<f32> = load %texture_plane1
     %17:tint_ExternalTextureParams = load %texture_params
-    %result:vec4<f32> = call %foo, %15, %16, %17, %sampler_1, %coords_1
-    ret %result
+    %result_1:vec4<f32> = call %foo, %15, %16, %17, %sampler_1, %coords_1  # %result_1: 'result'
+    ret %result_1
   }
 }
 %tint_TextureSampleExternal = func(%plane_0:texture_2d<f32>, %plane_1:texture_2d<f32>, %params:tint_ExternalTextureParams, %sampler_2:sampler, %coords_2:vec2<f32>):vec4<f32> {  # %sampler_2: 'sampler', %coords_2: 'coords'
@@ -1062,8 +1062,8 @@
 
 %foo = func(%texture_plane0_1:texture_2d<f32>, %texture_plane1_1:texture_2d<f32>, %texture_params_1:tint_ExternalTextureParams, %sampler:sampler, %coords:vec2<f32>):vec4<f32> {  # %texture_plane0_1: 'texture_plane0', %texture_plane1_1: 'texture_plane1', %texture_params_1: 'texture_params'
   $B2: {
-    %10:vec4<f32> = call %tint_TextureSampleExternal, %texture_plane0_1, %texture_plane1_1, %texture_params_1, %sampler, %coords
-    ret %10
+    %result:vec4<f32> = call %tint_TextureSampleExternal, %texture_plane0_1, %texture_plane1_1, %texture_params_1, %sampler, %coords
+    ret %result
   }
 }
 %bar = func(%sampler_1:sampler, %coords_1:vec2<f32>):vec4<f32> {  # %sampler_1: 'sampler', %coords_1: 'coords'
diff --git a/src/tint/lang/core/ir/transform/std140.cc b/src/tint/lang/core/ir/transform/std140.cc
index f2caaf5..a61b8bc 100644
--- a/src/tint/lang/core/ir/transform/std140.cc
+++ b/src/tint/lang/core/ir/transform/std140.cc
@@ -27,14 +27,23 @@
 
 #include "src/tint/lang/core/ir/transform/std140.h"
 
+#include <cstdint>
 #include <utility>
 
+#include "src/tint/lang/core/address_space.h"
 #include "src/tint/lang/core/ir/builder.h"
+#include "src/tint/lang/core/ir/function_param.h"
 #include "src/tint/lang/core/ir/module.h"
 #include "src/tint/lang/core/ir/validator.h"
 #include "src/tint/lang/core/type/array.h"
 #include "src/tint/lang/core/type/matrix.h"
+#include "src/tint/lang/core/type/memory_view.h"
+#include "src/tint/lang/core/type/pointer.h"
 #include "src/tint/lang/core/type/struct.h"
+#include "src/tint/lang/core/type/type.h"
+#include "src/tint/utils/containers/hashmap.h"
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/text/string_stream.h"
 
 using namespace tint::core::fluent_types;     // NOLINT
 using namespace tint::core::number_suffixes;  // NOLINT
@@ -73,43 +82,51 @@
         }
 
         // Find uniform buffers that contain matrices that need to be decomposed.
-        Vector<Var*, 8> buffer_variables;
+        Vector<std::pair<Var*, const core::type::Type*>, 8> buffer_variables;
         for (auto inst : *ir.root_block) {
-            auto* var = inst->As<Var>();
-            if (!var || !var->Alive()) {
-                continue;
-            }
-            auto* ptr = var->Result(0)->Type()->As<core::type::Pointer>();
-            if (!ptr || ptr->AddressSpace() != core::AddressSpace::kUniform) {
-                continue;
-            }
-            if (RewriteType(ptr->StoreType()) != ptr->StoreType()) {
-                buffer_variables.Push(var);
+            if (auto* var = inst->As<Var>()) {
+                auto* ptr = var->Result(0)->Type()->As<core::type::Pointer>();
+                if (!ptr || ptr->AddressSpace() != core::AddressSpace::kUniform) {
+                    continue;
+                }
+                auto* store_type = RewriteType(ptr->StoreType());
+                if (store_type != ptr->StoreType()) {
+                    buffer_variables.Push(std::make_pair(var, store_type));
+                }
             }
         }
 
         // Now process the buffer variables, replacing them with new variables that have decomposed
         // matrices and updating all usages of the variables.
-        for (auto* var : buffer_variables) {
+        for (auto var_and_ty : buffer_variables) {
             // Create a new variable with the modified store type.
-            const auto& bp = var->BindingPoint();
-            auto* store_type = var->Result(0)->Type()->As<core::type::Pointer>()->StoreType();
-            auto* new_var = b.Var(ty.ptr(uniform, RewriteType(store_type)));
+            auto* old_var = var_and_ty.first;
+            auto* new_var = b.Var(ty.ptr(uniform, var_and_ty.second));
+            const auto& bp = old_var->BindingPoint();
             new_var->SetBindingPoint(bp->group, bp->binding);
-            if (auto name = ir.NameOf(var)) {
+            if (auto name = ir.NameOf(old_var)) {
                 ir.SetName(new_var->Result(0), name);
             }
 
-            // Replace every instruction that uses the original variable.
-            var->Result(0)->ForEachUse(
+            // Transform instructions that accessed the variable to use the decomposed var.
+            old_var->Result(0)->ForEachUse(
                 [&](Usage use) { Replace(use.instruction, new_var->Result(0)); });
 
             // Replace the original variable with the new variable.
-            var->ReplaceWith(new_var);
-            var->Destroy();
+            old_var->ReplaceWith(new_var);
+            old_var->Destroy();
         }
     }
 
+    /// @param type the type to check
+    /// @returns the matrix if @p type is a matrix that needs to be decomposed
+    static const core::type::Matrix* NeedsDecomposing(const core::type::Type* type) {
+        if (auto* mat = type->As<core::type::Matrix>(); mat && NeedsDecomposing(mat)) {
+            return mat;
+        }
+        return nullptr;
+    }
+
     /// @param mat the matrix type to check
     /// @returns true if @p mat needs to be decomposed
     static bool NeedsDecomposing(const core::type::Matrix* mat) {
@@ -125,10 +142,10 @@
     /// @param type the type to rewrite
     /// @returns the new type
     const core::type::Type* RewriteType(const core::type::Type* type) {
-        return rewritten_types.GetOrAdd(type, [&]() -> const core::type::Type* {
+        return rewritten_types.GetOrAdd(type, [&] {
             return tint::Switch(
                 type,
-                [&](const core::type::Array* arr) -> const core::type::Type* {
+                [&](const core::type::Array* arr) {
                     // Create a new array with element type potentially rewritten.
                     return ty.array(RewriteType(arr->ElemType()), arr->ConstantCount().value());
                 },
@@ -137,8 +154,7 @@
                     uint32_t member_index = 0;
                     Vector<const core::type::StructMember*, 4> new_members;
                     for (auto* member : str->Members()) {
-                        auto* mat = member->Type()->As<core::type::Matrix>();
-                        if (mat && NeedsDecomposing(mat)) {
+                        if (auto* mat = NeedsDecomposing(member->Type())) {
                             // Decompose these matrices into a separate member for each column.
                             member_index_map.Add(member, member_index);
                             auto* col = mat->ColumnType();
@@ -182,6 +198,32 @@
                     }
                     return new_str;
                 },
+                [&](const core::type::Matrix* mat) -> const core::type::Type* {
+                    if (!NeedsDecomposing(mat)) {
+                        return mat;
+                    }
+                    StringStream name;
+                    name << "mat" << mat->columns() << "x" << mat->rows() << "_"
+                         << mat->ColumnType()->type()->FriendlyName() << "_std140";
+                    Vector<core::type::StructMember*, 4> members;
+                    // Decompose these matrices into a separate member for each column.
+                    auto* col = mat->ColumnType();
+                    uint32_t offset = 0;
+                    for (uint32_t i = 0; i < mat->columns(); i++) {
+                        StringStream ss;
+                        ss << "col" << std::to_string(i);
+                        members.Push(ty.Get<core::type::StructMember>(
+                            sym.New(ss.str()), col, i, offset, col->Align(), col->Size(),
+                            core::type::StructMemberAttributes{}));
+                        offset += col->Align();
+                    }
+
+                    // Create a new struct with the rewritten members.
+                    return ty.Get<core::type::Struct>(
+                        sym.New(name.str()), std::move(members), col->Align(),
+                        col->Align() * mat->columns(),
+                        (col->Align() * (mat->columns() - 1)) + col->Size());
+                },
                 [&](Default) {
                     // This type cannot contain a matrix, so no changes needed.
                     return type;
@@ -189,19 +231,26 @@
         });
     }
 
-    /// Load a decomposed matrix from a structure.
+    /// Reconstructs a column-decomposed matrix.
     /// @param mat the matrix type
     /// @param root the root value being accessed into
-    /// @param indices the access indices that get to the first column of the decomposed matrix
+    /// @param indices the access indices that index the first column of the matrix.
     /// @returns the loaded matrix
-    Value* LoadMatrix(const core::type::Matrix* mat, Value* root, Vector<Value*, 4> indices) {
-        // Load each column vector from the struct and reconstruct the original matrix type.
+    Value* RebuildMatrix(const core::type::Matrix* mat, Value* root, VectorRef<Value*> indices) {
+        // Recombine each column vector from the struct and reconstruct the original matrix type.
+        bool is_ptr = root->Type()->Is<core::type::Pointer>();
+        Vector<Value*, 4> column_indices(std::move(indices));
         Vector<Value*, 4> args;
         auto first_column = indices.Back()->As<Constant>()->Value()->ValueAs<uint32_t>();
         for (uint32_t i = 0; i < mat->columns(); i++) {
-            indices.Back() = b.Constant(u32(first_column + i));
-            auto* access = b.Access(ty.ptr(uniform, mat->ColumnType()), root, indices);
-            args.Push(b.Load(access->Result(0))->Result(0));
+            column_indices.Back() = b.Constant(u32(first_column + i));
+            if (is_ptr) {
+                auto* access = b.Access(ty.ptr(uniform, mat->ColumnType()), root, column_indices);
+                args.Push(b.Load(access)->Result(0));
+            } else {
+                auto* access = b.Access(mat->ColumnType(), root, column_indices);
+                args.Push(access->Result(0));
+            }
         }
         return b.Construct(mat, std::move(args))->Result(0);
     }
@@ -228,16 +277,10 @@
                         uint32_t index = 0;
                         Vector<Value*, 4> args;
                         for (auto* member : str->Members()) {
-                            if (auto* mat = member->Type()->As<core::type::Matrix>();
-                                mat && NeedsDecomposing(mat)) {
-                                // Extract each decomposed column and reconstruct the matrix.
-                                Vector<Value*, 4> columns;
-                                for (uint32_t i = 0; i < mat->columns(); i++) {
-                                    auto* extract = b.Access(mat->ColumnType(), input, u32(index));
-                                    columns.Push(extract->Result(0));
-                                    index++;
-                                }
-                                args.Push(b.Construct(mat, std::move(columns))->Result(0));
+                            if (auto* mat = NeedsDecomposing(member->Type())) {
+                                args.Push(
+                                    RebuildMatrix(mat, input, Vector{b.Constant(u32(index))}));
+                                index += mat->columns();
                             } else {
                                 // Extract and convert the member.
                                 auto* type = input_str->Element(index);
@@ -268,6 +311,12 @@
                 });
                 return b.Load(new_arr)->Result(0);
             },
+            [&](const core::type::Matrix* mat) -> Value* {
+                if (!NeedsDecomposing(mat)) {
+                    return source;
+                }
+                return RebuildMatrix(mat, source, Vector{b.Constant(u32(0))});
+            },
             [&](Default) { return source; });
     }
 
@@ -279,28 +328,75 @@
             tint::Switch(
                 inst,  //
                 [&](Access* access) {
+                    auto* object_ty = access->Object()->Type()->As<core::type::MemoryView>();
+                    if (!object_ty || object_ty->AddressSpace() != core::AddressSpace::kUniform) {
+                        // Access to non-uniform memory views does not require transformation.
+                        return;
+                    }
+
+                    if (!replacement->Type()->Is<core::type::MemoryView>()) {
+                        // The replacement is a value, in which case the decomposed matrix has
+                        // already been reconstructed. In this situation the access only needs its
+                        // return type updating, and downstream instructions need updating.
+                        access->SetOperand(Access::kObjectOperandOffset, replacement);
+                        auto* result = access->Result(0);
+                        result->SetType(result->Type()->UnwrapPtrOrRef());
+                        result->ForEachUse([&](Usage use) { Replace(use.instruction, result); });
+                        return;
+                    }
+
                     // Modify the access indices to take decomposed matrices into account.
-                    auto* current_type = access->Object()->Type()->UnwrapPtr();
+                    auto* current_type = object_ty->StoreType();
                     Vector<Value*, 4> indices;
-                    for (auto idx : access->Indices()) {
-                        if (auto* str = current_type->As<core::type::Struct>()) {
+
+                    if (NeedsDecomposing(current_type)) {
+                        // Decomposed matrices are indexed using their first column vector
+                        indices.Push(b.Constant(0_u));
+                    }
+
+                    for (size_t i = 0, n = access->Indices().Length(); i < n; i++) {
+                        auto* idx = access->Indices()[i];
+
+                        if (auto* mat = NeedsDecomposing(current_type)) {
+                            // Access chain passes through decomposed matrix.
+                            if (auto* const_idx = idx->As<Constant>()) {
+                                // Column vector index is a constant.
+                                // Instead of loading the whole matrix, fold the access of the
+                                // matrix and the constant column index into an single access of
+                                // column vector member.
+                                auto* base_idx = indices.Back()->As<Constant>();
+                                indices.Back() =
+                                    b.Constant(u32(base_idx->Value()->ValueAs<uint32_t>() +
+                                                   const_idx->Value()->ValueAs<uint32_t>()));
+                                current_type = mat->ColumnType();
+                                i++;  // We've already consumed the column access
+                            } else {
+                                // Column vector index is dynamic.
+                                // Reconstruct the whole matrix and index that.
+                                replacement = RebuildMatrix(mat, replacement, std::move(indices));
+                                indices.Clear();
+                                indices.Push(idx);
+                                current_type = mat->ColumnType();
+                            }
+                        } else if (auto* str = current_type->As<core::type::Struct>()) {
+                            // Remap member index
                             uint32_t old_index = idx->As<Constant>()->Value()->ValueAs<uint32_t>();
                             uint32_t new_index = *member_index_map.Get(str->Members()[old_index]);
-                            indices.Push(b.Constant(u32(new_index)));
                             current_type = str->Element(old_index);
+                            indices.Push(b.Constant(u32(new_index)));
                         } else {
                             indices.Push(idx);
                             current_type = current_type->Elements().type;
+                            if (NeedsDecomposing(current_type)) {
+                                // Decomposed matrices are indexed using their first column vector
+                                indices.Push(b.Constant(0_u));
+                            }
                         }
+                    }
 
-                        // If we've hit a matrix that was decomposed, load the whole matrix.
-                        // Any additional accesses will extract columns instead of producing
-                        // pointers.
-                        if (auto* mat = current_type->As<core::type::Matrix>();
-                            mat && NeedsDecomposing(mat)) {
-                            replacement = LoadMatrix(mat, replacement, std::move(indices));
-                            indices.Clear();
-                        }
+                    if (auto* mat = NeedsDecomposing(current_type)) {
+                        replacement = RebuildMatrix(mat, replacement, std::move(indices));
+                        indices.Clear();
                     }
 
                     if (!indices.IsEmpty()) {
@@ -333,9 +429,7 @@
                     if (!replacement->Type()->Is<core::type::Pointer>()) {
                         // We have loaded a decomposed matrix and reconstructed it, so this is now
                         // extracting from a value type.
-                        auto* access =
-                            b.Access(load->Result(0)->Type(), replacement, load->Index());
-                        load->Result(0)->ReplaceAllUsesWith(access->Result(0));
+                        b.AccessWithResult(load->DetachResult(), replacement, load->Index());
                         load->Destroy();
                     } else {
                         // There was no decomposed matrix on the path to this instruction so just
diff --git a/src/tint/lang/core/ir/transform/std140.h b/src/tint/lang/core/ir/transform/std140.h
index f931546..89852db 100644
--- a/src/tint/lang/core/ir/transform/std140.h
+++ b/src/tint/lang/core/ir/transform/std140.h
@@ -41,6 +41,8 @@
 
 /// Std140 is a transform that rewrites matrix types in the uniform address space to conform to
 /// GLSL's std140 layout rules.
+/// @note requires the DirectVariableAccess transform to have been run first to remove uniform
+/// pointer parameters.
 /// @param module the module to transform
 /// @returns success or failure
 Result<SuccessType> Std140(Module& module);
diff --git a/src/tint/lang/core/ir/transform/std140_fuzz.cc b/src/tint/lang/core/ir/transform/std140_fuzz.cc
index 86e5730..091def8 100644
--- a/src/tint/lang/core/ir/transform/std140_fuzz.cc
+++ b/src/tint/lang/core/ir/transform/std140_fuzz.cc
@@ -28,12 +28,31 @@
 #include "src/tint/lang/core/ir/transform/std140.h"
 
 #include "src/tint/cmd/fuzz/ir/fuzz.h"
+#include "src/tint/lang/core/address_space.h"
+#include "src/tint/lang/core/ir/module.h"
 #include "src/tint/lang/core/ir/validator.h"
+#include "src/tint/lang/core/type/pointer.h"
 
 namespace tint::core::ir::transform {
 namespace {
 
+bool CanRun(Module& module) {
+    for (auto& fn : module.functions) {
+        for (auto* param : fn->Params()) {
+            if (auto* ptr = param->Type()->As<core::type::Pointer>();
+                ptr && ptr->AddressSpace() == core::AddressSpace::kUniform) {
+                return false;  // Requires the DirectVariableAccess transform
+            }
+        }
+    }
+    return true;
+}
+
 void Std140Fuzzer(Module& module) {
+    if (!CanRun(module)) {
+        return;
+    }
+
     if (auto res = Std140(module); res != Success) {
         return;
     }
diff --git a/src/tint/lang/core/ir/transform/std140_test.cc b/src/tint/lang/core/ir/transform/std140_test.cc
index dd55108..28a67fa 100644
--- a/src/tint/lang/core/ir/transform/std140_test.cc
+++ b/src/tint/lang/core/ir/transform/std140_test.cc
@@ -29,6 +29,8 @@
 
 #include <utility>
 
+#include "src/tint/lang/core/fluent_types.h"
+#include "src/tint/lang/core/ir/load_vector_element.h"
 #include "src/tint/lang/core/ir/transform/helper_test.h"
 #include "src/tint/lang/core/type/array.h"
 #include "src/tint/lang/core/type/matrix.h"
@@ -148,8 +150,7 @@
     EXPECT_EQ(expect, str());
 }
 
-// Test that we do not decompose a mat2x2 that is used an array element type.
-TEST_F(IR_Std140Test, NoModify_Mat2x2_InsideArray) {
+TEST_F(IR_Std140Test, Load_Mat2x2f_InArray) {
     auto* mat = ty.mat2x2<f32>();
     auto* structure =
         ty.Struct(mod.symbols.New("MyStruct"), {
@@ -186,7 +187,35 @@
 )";
     EXPECT_EQ(src, str());
 
-    auto* expect = src;
+    auto* expect = R"(
+MyStruct = struct @align(8), @block {
+  arr:array<mat2x2<f32>, 4> @offset(0)
+}
+
+mat2x2_f32_std140 = struct @align(8) {
+  col0:vec2<f32> @offset(0)
+  col1:vec2<f32> @offset(8)
+}
+
+MyStruct_std140 = struct @align(8), @block {
+  arr:array<mat2x2_f32_std140, 4> @offset(0)
+}
+
+$B1: {  # root
+  %buffer:ptr<uniform, MyStruct_std140, read> = var @binding_point(0, 0)
+}
+
+%foo = func():mat2x2<f32> {
+  $B2: {
+    %3:ptr<uniform, vec2<f32>, read> = access %buffer, 0u, 2u, 0u
+    %4:vec2<f32> = load %3
+    %5:ptr<uniform, vec2<f32>, read> = access %buffer, 0u, 2u, 1u
+    %6:vec2<f32> = load %5
+    %7:mat2x2<f32> = construct %4, %6
+    ret %7
+  }
+}
+)";
 
     Run(Std140);
 
@@ -264,7 +293,7 @@
     EXPECT_EQ(expect, str());
 }
 
-TEST_F(IR_Std140Test, Mat3x2_LoadColumn) {
+TEST_F(IR_Std140Test, Mat3x2_LoadConstantColumn) {
     auto* mat = ty.mat3x2<f32>();
     auto* structure = ty.Struct(mod.symbols.New("MyStruct"), {
                                                                  {mod.symbols.New("a"), mat},
@@ -318,15 +347,83 @@
 
 %foo = func():vec2<f32> {
   $B2: {
-    %3:ptr<uniform, vec2<f32>, read> = access %buffer, 0u
+    %3:ptr<uniform, vec2<f32>, read> = access %buffer, 1u
     %4:vec2<f32> = load %3
-    %5:ptr<uniform, vec2<f32>, read> = access %buffer, 1u
-    %6:vec2<f32> = load %5
-    %7:ptr<uniform, vec2<f32>, read> = access %buffer, 2u
-    %8:vec2<f32> = load %7
-    %9:mat3x2<f32> = construct %4, %6, %8
-    %10:vec2<f32> = access %9, 1u
-    ret %10
+    ret %4
+  }
+}
+)";
+
+    Run(Std140);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_Std140Test, Mat3x2_LoadDynamicColumn) {
+    auto* mat = ty.mat3x2<f32>();
+    auto* structure = ty.Struct(mod.symbols.New("MyStruct"), {
+                                                                 {mod.symbols.New("a"), mat},
+                                                             });
+    structure->SetStructFlag(core::type::kBlock);
+
+    auto* buffer = b.Var("buffer", ty.ptr(uniform, structure));
+    buffer->SetBindingPoint(0, 0);
+    mod.root_block->Append(buffer);
+
+    auto* func = b.Function("foo", mat->ColumnType());
+    auto* column = b.FunctionParam<i32>("column");
+    func->AppendParam(column);
+    b.Append(func->Block(), [&] {
+        auto* access = b.Access(ty.ptr(uniform, mat->ColumnType()), buffer, 0_u, column);
+        auto* load = b.Load(access);
+        b.Return(func, load);
+    });
+
+    auto* src = R"(
+MyStruct = struct @align(8), @block {
+  a:mat3x2<f32> @offset(0)
+}
+
+$B1: {  # root
+  %buffer:ptr<uniform, MyStruct, read> = var @binding_point(0, 0)
+}
+
+%foo = func(%column:i32):vec2<f32> {
+  $B2: {
+    %4:ptr<uniform, vec2<f32>, read> = access %buffer, 0u, %column
+    %5:vec2<f32> = load %4
+    ret %5
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+MyStruct = struct @align(8), @block {
+  a:mat3x2<f32> @offset(0)
+}
+
+MyStruct_std140 = struct @align(8), @block {
+  a_col0:vec2<f32> @offset(0)
+  a_col1:vec2<f32> @offset(8)
+  a_col2:vec2<f32> @offset(16)
+}
+
+$B1: {  # root
+  %buffer:ptr<uniform, MyStruct_std140, read> = var @binding_point(0, 0)
+}
+
+%foo = func(%column:i32):vec2<f32> {
+  $B2: {
+    %4:ptr<uniform, vec2<f32>, read> = access %buffer, 0u
+    %5:vec2<f32> = load %4
+    %6:ptr<uniform, vec2<f32>, read> = access %buffer, 1u
+    %7:vec2<f32> = load %6
+    %8:ptr<uniform, vec2<f32>, read> = access %buffer, 2u
+    %9:vec2<f32> = load %8
+    %10:mat3x2<f32> = construct %5, %7, %9
+    %11:vec2<f32> = access %10, %column
+    ret %11
   }
 }
 )";
@@ -390,16 +487,9 @@
 
 %foo = func():f32 {
   $B2: {
-    %3:ptr<uniform, vec2<f32>, read> = access %buffer, 0u
-    %4:vec2<f32> = load %3
-    %5:ptr<uniform, vec2<f32>, read> = access %buffer, 1u
-    %6:vec2<f32> = load %5
-    %7:ptr<uniform, vec2<f32>, read> = access %buffer, 2u
-    %8:vec2<f32> = load %7
-    %9:mat3x2<f32> = construct %4, %6, %8
-    %10:vec2<f32> = access %9, 1u
-    %11:f32 = access %10, 1u
-    ret %11
+    %3:ptr<uniform, vec2<f32>, read> = access %buffer, 1u
+    %4:f32 = load_vector_element %3, 1u
+    ret %4
   }
 }
 )";
@@ -1709,48 +1799,35 @@
 }
 %load_vec_b = func():f32 {
   $B7: {
-    %29:ptr<uniform, vec2<f32>, read> = access %buffer, 1u
+    %29:ptr<uniform, vec2<f32>, read> = access %buffer, 2u
     %30:vec2<f32> = load %29
-    %31:ptr<uniform, vec2<f32>, read> = access %buffer, 2u
-    %32:vec2<f32> = load %31
-    %33:ptr<uniform, vec2<f32>, read> = access %buffer, 3u
-    %34:vec2<f32> = load %33
-    %35:mat3x2<f32> = construct %30, %32, %34
-    %36:vec2<f32> = access %35, 1u
-    %37:f32 = access %36, 1u
-    ret %37
+    %31:f32 = access %30, 1u
+    ret %31
   }
 }
 %lve_a = func():f32 {
   $B8: {
-    %39:ptr<uniform, vec4<f32>, read> = access %buffer, 0u, 1u
-    %40:f32 = load_vector_element %39, 1u
-    ret %40
+    %33:ptr<uniform, vec4<f32>, read> = access %buffer, 0u, 1u
+    %34:f32 = load_vector_element %33, 1u
+    ret %34
   }
 }
 %lve_b = func():f32 {
   $B9: {
-    %42:ptr<uniform, vec2<f32>, read> = access %buffer, 1u
-    %43:vec2<f32> = load %42
-    %44:ptr<uniform, vec2<f32>, read> = access %buffer, 2u
-    %45:vec2<f32> = load %44
-    %46:ptr<uniform, vec2<f32>, read> = access %buffer, 3u
-    %47:vec2<f32> = load %46
-    %48:mat3x2<f32> = construct %43, %45, %47
-    %49:vec2<f32> = access %48, 1u
-    %50:f32 = access %49, 1u
-    ret %50
+    %36:ptr<uniform, vec2<f32>, read> = access %buffer, 2u
+    %37:f32 = load_vector_element %36, 1u
+    ret %37
   }
 }
 %convert_MyStruct = func(%input:MyStruct_std140):MyStruct {
   $B10: {
-    %52:mat4x4<f32> = access %input, 0u
-    %53:vec2<f32> = access %input, 1u
-    %54:vec2<f32> = access %input, 2u
-    %55:vec2<f32> = access %input, 3u
-    %56:mat3x2<f32> = construct %53, %54, %55
-    %57:MyStruct = construct %52, %56
-    ret %57
+    %39:mat4x4<f32> = access %input, 0u
+    %40:vec2<f32> = access %input, 1u
+    %41:vec2<f32> = access %input, 2u
+    %42:vec2<f32> = access %input, 3u
+    %43:mat3x2<f32> = construct %40, %41, %42
+    %44:MyStruct = construct %39, %43
+    ret %44
   }
 }
 )";
@@ -1857,48 +1934,295 @@
     %14:vec4<f16> = load %13
     %15:mat4x4<f16> = construct %8, %10, %12, %14
     %mat:mat4x4<f16> = let %15
-    %17:ptr<uniform, vec3<f16>, read> = access %buffer, 4u
+    %17:ptr<uniform, vec3<f16>, read> = access %buffer, 5u
     %18:vec3<f16> = load %17
-    %19:ptr<uniform, vec3<f16>, read> = access %buffer, 5u
-    %20:vec3<f16> = load %19
-    %21:ptr<uniform, vec3<f16>, read> = access %buffer, 6u
-    %22:vec3<f16> = load %21
-    %23:ptr<uniform, vec3<f16>, read> = access %buffer, 7u
-    %24:vec3<f16> = load %23
-    %25:mat4x3<f16> = construct %18, %20, %22, %24
-    %26:vec3<f16> = access %25, 1u
-    %col:vec3<f16> = let %26
-    %28:ptr<uniform, vec4<f16>, read> = access %buffer, 2u
-    %29:vec4<f16> = load %28
-    %30:ptr<uniform, vec4<f16>, read> = access %buffer, 3u
-    %31:vec4<f16> = load %30
-    %32:mat2x4<f16> = construct %29, %31
-    %33:vec4<f16> = access %32, 0u
-    %34:f16 = access %33, 3u
-    %el:f16 = let %34
+    %col:vec3<f16> = let %18
+    %20:ptr<uniform, vec4<f16>, read> = access %buffer, 2u
+    %21:f16 = load_vector_element %20, 3u
+    %el:f16 = let %21
     ret
   }
 }
 %convert_MyStruct = func(%input:MyStruct_std140):MyStruct {
   $B3: {
-    %37:vec2<f16> = access %input, 0u
-    %38:vec2<f16> = access %input, 1u
-    %39:mat2x2<f16> = construct %37, %38
-    %40:vec4<f16> = access %input, 2u
-    %41:vec4<f16> = access %input, 3u
-    %42:mat2x4<f16> = construct %40, %41
-    %43:vec3<f16> = access %input, 4u
-    %44:vec3<f16> = access %input, 5u
-    %45:vec3<f16> = access %input, 6u
-    %46:vec3<f16> = access %input, 7u
-    %47:mat4x3<f16> = construct %43, %44, %45, %46
-    %48:vec4<f16> = access %input, 8u
-    %49:vec4<f16> = access %input, 9u
-    %50:vec4<f16> = access %input, 10u
-    %51:vec4<f16> = access %input, 11u
-    %52:mat4x4<f16> = construct %48, %49, %50, %51
-    %53:MyStruct = construct %39, %42, %47, %52
-    ret %53
+    %24:vec2<f16> = access %input, 0u
+    %25:vec2<f16> = access %input, 1u
+    %26:mat2x2<f16> = construct %24, %25
+    %27:vec4<f16> = access %input, 2u
+    %28:vec4<f16> = access %input, 3u
+    %29:mat2x4<f16> = construct %27, %28
+    %30:vec3<f16> = access %input, 4u
+    %31:vec3<f16> = access %input, 5u
+    %32:vec3<f16> = access %input, 6u
+    %33:vec3<f16> = access %input, 7u
+    %34:mat4x3<f16> = construct %30, %31, %32, %33
+    %35:vec4<f16> = access %input, 8u
+    %36:vec4<f16> = access %input, 9u
+    %37:vec4<f16> = access %input, 10u
+    %38:vec4<f16> = access %input, 11u
+    %39:mat4x4<f16> = construct %35, %36, %37, %38
+    %40:MyStruct = construct %26, %29, %34, %39
+    ret %40
+  }
+}
+)";
+
+    Run(Std140);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_Std140Test, Mat3x3f_And_ArrayMat4x3f) {  // crbug.com/338727551
+    auto* s =
+        ty.Struct(mod.symbols.New("S"), {
+                                            {mod.symbols.New("a"), ty.mat3x3<f32>()},
+                                            {mod.symbols.New("b"), ty.array<mat4x3<f32>, 3>()},
+                                        });
+    s->SetStructFlag(core::type::kBlock);
+
+    auto* u = b.Var("u", ty.ptr(uniform, s));
+    u->SetBindingPoint(0, 0);
+    mod.root_block->Append(u);
+
+    auto* f = b.Function("F", ty.f32());
+    b.Append(f->Block(), [&] {
+        auto* p = b.Access<ptr<uniform, vec3<f32>, read>>(u, 1_u, 0_u, 0_u);
+        auto* x = b.LoadVectorElement(p, 0_u);
+        b.Return(f, x);
+    });
+
+    auto* src = R"(
+S = struct @align(16), @block {
+  a:mat3x3<f32> @offset(0)
+  b:array<mat4x3<f32>, 3> @offset(48)
+}
+
+$B1: {  # root
+  %u:ptr<uniform, S, read> = var @binding_point(0, 0)
+}
+
+%F = func():f32 {
+  $B2: {
+    %3:ptr<uniform, vec3<f32>, read> = access %u, 1u, 0u, 0u
+    %4:f32 = load_vector_element %3, 0u
+    ret %4
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+S = struct @align(16), @block {
+  a:mat3x3<f32> @offset(0)
+  b:array<mat4x3<f32>, 3> @offset(48)
+}
+
+mat4x3_f32_std140 = struct @align(16) {
+  col0:vec3<f32> @offset(0)
+  col1:vec3<f32> @offset(16)
+  col2:vec3<f32> @offset(32)
+  col3:vec3<f32> @offset(48)
+}
+
+S_std140 = struct @align(16), @block {
+  a_col0:vec3<f32> @offset(0)
+  a_col1:vec3<f32> @offset(16)
+  a_col2:vec3<f32> @offset(32)
+  b:array<mat4x3_f32_std140, 3> @offset(48)
+}
+
+$B1: {  # root
+  %u:ptr<uniform, S_std140, read> = var @binding_point(0, 0)
+}
+
+%F = func():f32 {
+  $B2: {
+    %3:ptr<uniform, vec3<f32>, read> = access %u, 3u, 0u, 0u
+    %4:f32 = load_vector_element %3, 0u
+    ret %4
+  }
+}
+)";
+
+    Run(Std140);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_Std140Test, Mat3x3f_And_ArrayStructMat4x3f) {
+    auto* s1 =
+        ty.Struct(mod.symbols.New("S1"), {
+                                             {mod.symbols.New("c"), ty.mat3x3<f32>()},
+                                             {mod.symbols.New("d"), ty.array<mat4x3<f32>, 3>()},
+                                         });
+    auto* s2 = ty.Struct(mod.symbols.New("S2"), {
+                                                    {mod.symbols.New("a"), ty.mat3x3<f32>()},
+                                                    {mod.symbols.New("b"), s1},
+                                                });
+    s2->SetStructFlag(core::type::kBlock);
+
+    auto* u = b.Var("u", ty.ptr(uniform, s2));
+    u->SetBindingPoint(0, 0);
+    mod.root_block->Append(u);
+
+    auto* f = b.Function("F", ty.f32());
+    b.Append(f->Block(), [&] {
+        auto* p = b.Access<ptr<uniform, vec3<f32>, read>>(u, 1_u, 1_u, 0_u, 0_u);
+        auto* x = b.LoadVectorElement(p, 0_u);
+        b.Return(f, x);
+    });
+
+    auto* src = R"(
+S1 = struct @align(16) {
+  c:mat3x3<f32> @offset(0)
+  d:array<mat4x3<f32>, 3> @offset(48)
+}
+
+S2 = struct @align(16), @block {
+  a:mat3x3<f32> @offset(0)
+  b:S1 @offset(48)
+}
+
+$B1: {  # root
+  %u:ptr<uniform, S2, read> = var @binding_point(0, 0)
+}
+
+%F = func():f32 {
+  $B2: {
+    %3:ptr<uniform, vec3<f32>, read> = access %u, 1u, 1u, 0u, 0u
+    %4:f32 = load_vector_element %3, 0u
+    ret %4
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+S1 = struct @align(16) {
+  c:mat3x3<f32> @offset(0)
+  d:array<mat4x3<f32>, 3> @offset(48)
+}
+
+S2 = struct @align(16), @block {
+  a:mat3x3<f32> @offset(0)
+  b:S1 @offset(48)
+}
+
+mat4x3_f32_std140 = struct @align(16) {
+  col0:vec3<f32> @offset(0)
+  col1:vec3<f32> @offset(16)
+  col2:vec3<f32> @offset(32)
+  col3:vec3<f32> @offset(48)
+}
+
+S1_std140 = struct @align(16) {
+  c_col0:vec3<f32> @offset(0)
+  c_col1:vec3<f32> @offset(16)
+  c_col2:vec3<f32> @offset(32)
+  d:array<mat4x3_f32_std140, 3> @offset(48)
+}
+
+S2_std140 = struct @align(16), @block {
+  a_col0:vec3<f32> @offset(0)
+  a_col1:vec3<f32> @offset(16)
+  a_col2:vec3<f32> @offset(32)
+  b:S1_std140 @offset(48)
+}
+
+$B1: {  # root
+  %u:ptr<uniform, S2_std140, read> = var @binding_point(0, 0)
+}
+
+%F = func():f32 {
+  $B2: {
+    %3:ptr<uniform, vec3<f32>, read> = access %u, 3u, 3u, 0u, 0u
+    %4:f32 = load_vector_element %3, 0u
+    ret %4
+  }
+}
+)";
+
+    Run(Std140);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_Std140Test, Mat3x3f_And_ArrayStructMat2x2f) {
+    auto* s1 = ty.Struct(mod.symbols.New("S1"), {
+                                                    {mod.symbols.New("c"), ty.mat2x2<f32>()},
+                                                });
+    auto* s2 = ty.Struct(mod.symbols.New("S2"), {
+                                                    {mod.symbols.New("a"), ty.mat3x3<f32>()},
+                                                    {mod.symbols.New("b"), s1},
+                                                });
+    s2->SetStructFlag(core::type::kBlock);
+
+    auto* u = b.Var("u", ty.ptr(uniform, s2));
+    u->SetBindingPoint(0, 0);
+    mod.root_block->Append(u);
+
+    auto* f = b.Function("F", ty.f32());
+    b.Append(f->Block(), [&] {
+        auto* p = b.Access<ptr<uniform, vec2<f32>, read>>(u, 1_u, 0_u, 0_u);
+        auto* x = b.LoadVectorElement(p, 0_u);
+        b.Return(f, x);
+    });
+
+    auto* src = R"(
+S1 = struct @align(8) {
+  c:mat2x2<f32> @offset(0)
+}
+
+S2 = struct @align(16), @block {
+  a:mat3x3<f32> @offset(0)
+  b:S1 @offset(48)
+}
+
+$B1: {  # root
+  %u:ptr<uniform, S2, read> = var @binding_point(0, 0)
+}
+
+%F = func():f32 {
+  $B2: {
+    %3:ptr<uniform, vec2<f32>, read> = access %u, 1u, 0u, 0u
+    %4:f32 = load_vector_element %3, 0u
+    ret %4
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+S1 = struct @align(8) {
+  c:mat2x2<f32> @offset(0)
+}
+
+S2 = struct @align(16), @block {
+  a:mat3x3<f32> @offset(0)
+  b:S1 @offset(48)
+}
+
+S1_std140 = struct @align(8) {
+  c_col0:vec2<f32> @offset(0)
+  c_col1:vec2<f32> @offset(8)
+}
+
+S2_std140 = struct @align(16), @block {
+  a_col0:vec3<f32> @offset(0)
+  a_col1:vec3<f32> @offset(16)
+  a_col2:vec3<f32> @offset(32)
+  b:S1_std140 @offset(48)
+}
+
+$B1: {  # root
+  %u:ptr<uniform, S2_std140, read> = var @binding_point(0, 0)
+}
+
+%F = func():f32 {
+  $B2: {
+    %3:ptr<uniform, vec2<f32>, read> = access %u, 3u, 0u
+    %4:f32 = load_vector_element %3, 0u
+    ret %4
   }
 }
 )";
diff --git a/src/tint/lang/core/ir/transform/vectorize_scalar_matrix_constructors.cc b/src/tint/lang/core/ir/transform/vectorize_scalar_matrix_constructors.cc
index 37bdb03..99d6be9 100644
--- a/src/tint/lang/core/ir/transform/vectorize_scalar_matrix_constructors.cc
+++ b/src/tint/lang/core/ir/transform/vectorize_scalar_matrix_constructors.cc
@@ -86,8 +86,7 @@
         }
 
         // Construct the matrix from the column vectors and replace the original instruction.
-        auto* replacement = b.Construct(mat, std::move(columns))->Result(0);
-        construct->Result(0)->ReplaceAllUsesWith(replacement);
+        b.ConstructWithResult(construct->DetachResult(), std::move(columns));
         construct->Destroy();
     }
 };
diff --git a/src/tint/lang/core/ir/transform/zero_init_workgroup_memory.cc b/src/tint/lang/core/ir/transform/zero_init_workgroup_memory.cc
index 26cbf76..8969c9f 100644
--- a/src/tint/lang/core/ir/transform/zero_init_workgroup_memory.cc
+++ b/src/tint/lang/core/ir/transform/zero_init_workgroup_memory.cc
@@ -32,6 +32,7 @@
 
 #include "src/tint/lang/core/ir/builder.h"
 #include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/transform/common/referenced_module_vars.h"
 #include "src/tint/lang/core/ir/validator.h"
 #include "src/tint/utils/containers/reverse.h"
 
@@ -61,17 +62,12 @@
     /// The type manager.
     core::type::Manager& ty{ir.Types()};
 
-    /// VarSet is a hash set of workgroup variables.
-    using VarSet = Hashset<Var*, 8>;
-
-    /// A map from variable to an ID used for sorting.
-    Hashmap<Var*, uint32_t, 8> var_to_id{};
-
-    /// A map from blocks to their directly referenced workgroup variables.
-    Hashmap<Block*, VarSet, 64> block_to_direct_vars{};
-
-    /// A map from functions to their transitively referenced workgroup variables.
-    Hashmap<Function*, VarSet, 8> function_to_transitive_vars{};
+    /// The mapping from functions to their transitively referenced workgroup variables.
+    ReferencedModuleVars referenced_module_vars_{
+        ir, [](const Var* var) {
+            auto* view = var->Result(0)->Type()->As<type::MemoryView>();
+            return view && view->AddressSpace() == AddressSpace::kWorkgroup;
+        }};
 
     /// ArrayIndex represents a required array index for an access instruction.
     struct ArrayIndex {
@@ -104,22 +100,6 @@
         if (ir.root_block->IsEmpty()) {
             return;
         }
-
-        // Loop over module-scope variables, looking for workgroup variables.
-        uint32_t next_id = 0;
-        for (auto inst : *ir.root_block) {
-            if (auto* var = inst->As<Var>()) {
-                auto* ptr = var->Result(0)->Type()->As<core::type::Pointer>();
-                if (ptr && ptr->AddressSpace() == core::AddressSpace::kWorkgroup) {
-                    // Record the usage of the variable for each block that references it.
-                    var->Result(0)->ForEachUse([&](const Usage& use) {
-                        block_to_direct_vars.GetOrAddZero(use.instruction->Block()).Add(var);
-                    });
-                    var_to_id.Add(var, next_id++);
-                }
-            }
-        }
-
         // Process each entry point function.
         for (auto& func : ir.functions) {
             if (func->Stage() == Function::PipelineStage::kCompute) {
@@ -132,20 +112,14 @@
     /// @param func the entry point function
     void ProcessEntryPoint(Function* func) {
         // Get list of transitively referenced workgroup variables.
-        auto vars = GetReferencedVars(func);
+        const auto& vars = referenced_module_vars_.TransitiveReferences(func);
         if (vars.IsEmpty()) {
             return;
         }
 
-        // Sort the variables to get deterministic output in tests.
-        auto sorted_vars = vars.Vector();
-        sorted_vars.Sort([&](Var* first, Var* second) {
-            return *var_to_id.Get(first) < *var_to_id.Get(second);
-        });
-
         // Build list of store descriptors for all workgroup variables.
         StoreMap stores;
-        for (auto* var : sorted_vars) {
+        for (auto* var : vars) {
             PrepareStores(var, var->Result(0)->Type()->UnwrapPtr(), 1, {}, stores);
         }
 
@@ -188,46 +162,6 @@
         });
     }
 
-    /// Get the set of workgroup variables transitively referenced by @p func.
-    /// @param func the function
-    /// @returns the set of transitively referenced workgroup variables
-    VarSet GetReferencedVars(Function* func) {
-        return function_to_transitive_vars.GetOrAdd(func, [&] {
-            VarSet vars;
-            GetReferencedVars(func->Block(), vars);
-            return vars;
-        });
-    }
-
-    /// Get the set of workgroup variables transitively referenced by @p block.
-    /// @param block the block
-    /// @param vars the set of transitively referenced workgroup variables to populate
-    void GetReferencedVars(Block* block, VarSet& vars) {
-        // Add directly referenced vars.
-        if (auto itr = block_to_direct_vars.Get(block)) {
-            for (auto& var : *itr) {
-                vars.Add(var);
-            }
-        }
-
-        // Loop over instructions in the block.
-        for (auto* inst : *block) {
-            tint::Switch(
-                inst,
-                [&](UserCall* call) {
-                    // Get variables referenced by a function called from this block.
-                    auto callee_vars = GetReferencedVars(call->Target());
-                    for (auto& var : callee_vars) {
-                        vars.Add(var);
-                    }
-                },
-                [&](ControlInstruction* ctrl) {
-                    // Recurse into control instructions and gather their referenced vars.
-                    ctrl->ForeachBlock([&](Block* blk) { GetReferencedVars(blk, vars); });
-                });
-        }
-    }
-
     /// Recursively generate store descriptors for a workgroup variable.
     /// Determines the combined array iteration count of each inner element.
     /// @param var the workgroup variable
diff --git a/src/tint/lang/core/ir/transform/zero_init_workgroup_memory_test.cc b/src/tint/lang/core/ir/transform/zero_init_workgroup_memory_test.cc
index bf1a82b..1843665 100644
--- a/src/tint/lang/core/ir/transform/zero_init_workgroup_memory_test.cc
+++ b/src/tint/lang/core/ir/transform/zero_init_workgroup_memory_test.cc
@@ -102,6 +102,48 @@
     EXPECT_EQ(expect, str());
 }
 
+TEST_F(IR_ZeroInitWorkgroupMemoryTest, NonWorkgroupVar) {
+    auto* var = b.Var("pvar", ty.ptr(private_, ty.bool_()));
+    mod.root_block->Append(var);
+
+    auto* func = MakeEntryPoint("main", 1, 1, 1);
+    b.Append(func->Block(), [&] {  //
+        b.Load(var);
+        b.Return(func);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %pvar:ptr<private, bool, read_write> = var
+}
+
+%main = @compute @workgroup_size(1, 1, 1) func():void {
+  $B2: {
+    %3:bool = load %pvar
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %pvar:ptr<private, bool, read_write> = var
+}
+
+%main = @compute @workgroup_size(1, 1, 1) func():void {
+  $B2: {
+    %3:bool = load %pvar
+    ret
+  }
+}
+)";
+
+    Run(ZeroInitWorkgroupMemory);
+
+    EXPECT_EQ(expect, str());
+}
+
 TEST_F(IR_ZeroInitWorkgroupMemoryTest, ScalarBool) {
     auto* var = MakeVar("wgvar", ty.bool_());
 
diff --git a/src/tint/lang/core/ir/user_call_test.cc b/src/tint/lang/core/ir/user_call_test.cc
index 768fea3..252375d 100644
--- a/src/tint/lang/core/ir/user_call_test.cc
+++ b/src/tint/lang/core/ir/user_call_test.cc
@@ -58,7 +58,7 @@
 }
 
 TEST_F(IR_UserCallTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/validator.cc b/src/tint/lang/core/ir/validator.cc
index e945cc6..3e0e704 100644
--- a/src/tint/lang/core/ir/validator.cc
+++ b/src/tint/lang/core/ir/validator.cc
@@ -80,7 +80,6 @@
 #include "src/tint/utils/containers/transform.h"
 #include "src/tint/utils/ice/ice.h"
 #include "src/tint/utils/macros/defer.h"
-#include "src/tint/utils/macros/scoped_assignment.h"
 #include "src/tint/utils/rtti/switch.h"
 #include "src/tint/utils/text/styled_text.h"
 #include "src/tint/utils/text/text_style.h"
@@ -448,6 +447,7 @@
 }
 
 diag::Diagnostic& Validator::AddError(const Instruction* inst) {
+    diagnostics_.ReserveAdditional(2);  // Ensure diagnostics don't resize alive after AddNote()
     auto src = Disassembly().InstructionSource(inst);
     auto& diag = AddError(src) << inst->FriendlyName() << ": ";
 
@@ -458,6 +458,7 @@
 }
 
 diag::Diagnostic& Validator::AddError(const Instruction* inst, size_t idx) {
+    diagnostics_.ReserveAdditional(2);  // Ensure diagnostics don't resize alive after AddNote()
     auto src =
         Disassembly().OperandSource(Disassembly::IndexedValue{inst, static_cast<uint32_t>(idx)});
     auto& diag = AddError(src) << inst->FriendlyName() << ": ";
@@ -469,6 +470,7 @@
 }
 
 diag::Diagnostic& Validator::AddResultError(const Instruction* inst, size_t idx) {
+    diagnostics_.ReserveAdditional(2);  // Ensure diagnostics don't resize alive after AddNote()
     auto src =
         Disassembly().ResultSource(Disassembly::IndexedValue{inst, static_cast<uint32_t>(idx)});
     auto& diag = AddError(src) << inst->FriendlyName() << ": ";
@@ -789,7 +791,10 @@
 void Validator::CheckVar(const Var* var) {
     if (var->Result(0) && var->Initializer()) {
         if (var->Initializer()->Type() != var->Result(0)->Type()->UnwrapPtrOrRef()) {
-            AddError(var) << "initializer has incorrect type";
+            AddError(var) << "initializer type "
+                          << style::Type(var->Initializer()->Type()->FriendlyName())
+                          << " does not match store type "
+                          << style::Type(var->Result(0)->Type()->UnwrapPtrOrRef()->FriendlyName());
         }
     }
 }
@@ -799,7 +804,9 @@
 
     if (let->Result(0) && let->Value()) {
         if (let->Result(0)->Type() != let->Value()->Type()) {
-            AddError(let) << "result type does not match value type";
+            AddError(let) << "result type " << style::Type(let->Result(0)->Type()->FriendlyName())
+                          << " does not match value type "
+                          << style::Type(let->Value()->Type()->FriendlyName());
         }
     }
 }
@@ -859,8 +866,9 @@
     for (size_t i = 0; i < args.Length(); i++) {
         if (args[i]->Type() != params[i]->Type()) {
             AddError(call, UserCall::kArgsOperandOffset + i)
-                << "function parameter " << i << " is of type " << params[i]->Type()->FriendlyName()
-                << ", but argument is of type " << args[i]->Type()->FriendlyName();
+                << "function parameter " << i << " is of type "
+                << style::Type(params[i]->Type()->FriendlyName()) << ", but argument is of type "
+                << style::Type(args[i]->Type()->FriendlyName());
         }
     }
 }
@@ -880,13 +888,15 @@
     auto desc_of = [&](Kind kind, const core::type::Type* type) {
         switch (kind) {
             case kPtr:
-                return StyledText{} << "ptr<" << obj_view->AddressSpace() << ", "
-                                    << type->FriendlyName() << ", " << obj_view->Access() << ">";
+                return StyledText{}
+                       << style::Type("ptr<", obj_view->AddressSpace(), ", ", type->FriendlyName(),
+                                      ", ", obj_view->Access(), ">");
             case kRef:
-                return StyledText{} << "ref<" << obj_view->AddressSpace() << ", "
-                                    << type->FriendlyName() << ", " << obj_view->Access() << ">";
+                return StyledText{}
+                       << style::Type("ref<", obj_view->AddressSpace(), ", ", type->FriendlyName(),
+                                      ", ", obj_view->Access(), ">");
             default:
-                return StyledText{} << type->FriendlyName();
+                return StyledText{} << style::Type(type->FriendlyName());
         }
     };
 
@@ -957,7 +967,7 @@
 
     if (TINT_UNLIKELY(!ok)) {
         AddError(a) << "result of access chain is type " << desc_of(in_kind, ty)
-                    << " but instruction type is " << want->FriendlyName();
+                    << " but instruction type is " << style::Type(want->FriendlyName());
     }
 }
 
@@ -966,11 +976,7 @@
     if (b->LHS() && b->RHS()) {
         auto symbols = SymbolTable::Wrap(mod_.symbols);
         auto type_mgr = type::Manager::Wrap(mod_.Types());
-        intrinsic::Context context{
-            b->TableData(),
-            type_mgr,
-            symbols,
-        };
+        intrinsic::Context context{b->TableData(), type_mgr, symbols};
 
         auto overload =
             core::intrinsic::LookupBinary(context, b->Op(), b->LHS()->Type(), b->RHS()->Type(),
@@ -982,11 +988,10 @@
 
         if (auto* result = b->Result(0)) {
             if (overload->return_type != result->Type()) {
-                StringStream err;
-                err << "binary instruction result type (" << result->Type()->FriendlyName()
-                    << ") does not match overload result type ("
-                    << overload->return_type->FriendlyName() << ")";
-                AddError(b) << err.str();
+                AddError(b) << "result value type " << style::Type(result->Type()->FriendlyName())
+                            << " does not match "
+                            << style::Instruction(Disassembly().NameOf(b->Op())) << " result type "
+                            << style::Type(overload->return_type->FriendlyName());
             }
         }
     }
@@ -997,11 +1002,7 @@
     if (u->Val()) {
         auto symbols = SymbolTable::Wrap(mod_.symbols);
         auto type_mgr = type::Manager::Wrap(mod_.Types());
-        intrinsic::Context context{
-            u->TableData(),
-            type_mgr,
-            symbols,
-        };
+        intrinsic::Context context{u->TableData(), type_mgr, symbols};
 
         auto overload = core::intrinsic::LookupUnary(context, u->Op(), u->Val()->Type(),
                                                      core::EvaluationStage::kRuntime);
@@ -1012,11 +1013,10 @@
 
         if (auto* result = u->Result(0)) {
             if (overload->return_type != result->Type()) {
-                StringStream err;
-                err << "unary instruction result type (" << result->Type()->FriendlyName()
-                    << ") does not match overload result type ("
-                    << overload->return_type->FriendlyName() << ")";
-                AddError(u) << err.str();
+                AddError(u) << "result value type " << style::Type(result->Type()->FriendlyName())
+                            << " does not match "
+                            << style::Instruction(Disassembly().NameOf(u->Op())) << " result type "
+                            << style::Type(overload->return_type->FriendlyName());
             }
         }
     }
@@ -1026,7 +1026,8 @@
     CheckOperandNotNull(if_, if_->Condition(), If::kConditionOperandOffset);
 
     if (if_->Condition() && !if_->Condition()->Type()->Is<core::type::Bool>()) {
-        AddError(if_, If::kConditionOperandOffset) << "condition must be a `bool` type";
+        AddError(if_, If::kConditionOperandOffset)
+            << "condition type must be " << style::Type("bool");
     }
 
     tasks_.Push([this] { control_stack_.Pop(); });
@@ -1114,9 +1115,9 @@
 
     for (size_t i = 0; i < results.Length(); ++i) {
         if (results[i] && args[i] && results[i]->Type() != args[i]->Type()) {
-            AddError(e, i) << "argument type (" << results[i]->Type()->FriendlyName()
-                           << ") does not match control instruction type ("
-                           << args[i]->Type()->FriendlyName() << ")";
+            AddError(e, i) << "argument type " << style::Type(results[i]->Type()->FriendlyName())
+                           << " does not match control instruction type "
+                           << style::Type(args[i]->Type()->FriendlyName());
             AddNote(e->ControlInstruction()) << "control instruction";
         }
     }
@@ -1150,7 +1151,10 @@
         if (!ret->Value()) {
             AddError(ret) << "expected return value";
         } else if (ret->Value()->Type() != func->ReturnType()) {
-            AddError(ret) << "return value type does not match function return type";
+            AddError(ret) << "return value type "
+                          << style::Type(ret->Value()->Type()->FriendlyName())
+                          << " does not match function return type "
+                          << style::Type(func->ReturnType()->FriendlyName());
         }
     }
 }
@@ -1214,7 +1218,10 @@
             return;
         }
         if (l->Result(0)->Type() != mv->StoreType()) {
-            AddError(l, Load::kFromOperandOffset) << "result type does not match source store type";
+            AddError(l, Load::kFromOperandOffset)
+                << "result type " << style::Type(l->Result(0)->Type()->FriendlyName())
+                << " does not match source store type "
+                << style::Type(mv->StoreType()->FriendlyName());
         }
     }
 }
@@ -1230,8 +1237,12 @@
                     << "store target operand is not a memory view";
                 return;
             }
-            if (from->Type() != mv->StoreType()) {
-                AddError(s, Store::kFromOperandOffset) << "value type does not match store type";
+            auto* value_type = from->Type();
+            auto* store_type = mv->StoreType();
+            if (value_type != store_type) {
+                AddError(s, Store::kFromOperandOffset)
+                    << "value type " << style::Type(value_type->FriendlyName())
+                    << " does not match store type " << style::Type(store_type->FriendlyName());
             }
         }
     }
@@ -1245,7 +1256,9 @@
     if (auto* res = l->Result(0)) {
         if (auto* el_ty = GetVectorPtrElementType(l, LoadVectorElement::kFromOperandOffset)) {
             if (res->Type() != el_ty) {
-                AddResultError(l, 0) << "result type does not match vector pointer element type";
+                AddResultError(l, 0) << "result type " << style::Type(res->Type()->FriendlyName())
+                                     << " does not match vector pointer element type "
+                                     << style::Type(el_ty->FriendlyName());
             }
         }
     }
@@ -1260,7 +1273,9 @@
         if (auto* el_ty = GetVectorPtrElementType(s, StoreVectorElement::kToOperandOffset)) {
             if (value->Type() != el_ty) {
                 AddError(s, StoreVectorElement::kValueOperandOffset)
-                    << "value type does not match vector pointer element type";
+                    << "value type " << style::Type(value->Type()->FriendlyName())
+                    << " does not match vector pointer element type "
+                    << style::Type(el_ty->FriendlyName());
             }
         }
     }
@@ -1285,7 +1300,8 @@
         }
     }
 
-    AddError(inst, idx) << "operand must be a pointer to vector, got " << type->FriendlyName();
+    AddError(inst, idx) << "operand must be a pointer to vector, got "
+                        << style::Type(type->FriendlyName());
     return nullptr;
 }
 
diff --git a/src/tint/lang/core/ir/validator_test.cc b/src/tint/lang/core/ir/validator_test.cc
index 864e24d..2619338 100644
--- a/src/tint/lang/core/ir/validator_test.cc
+++ b/src/tint/lang/core/ir/validator_test.cc
@@ -394,8 +394,9 @@
 
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
-    EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:8:28 error: call: function parameter 1 is of type i32, but argument is of type f32
+    EXPECT_EQ(
+        res.Failure().reason.Str(),
+        R"(:8:28 error: call: function parameter 1 is of type 'i32', but argument is of type 'f32'
     %6:void = call %g, 1i, 2.0f, 3i
                            ^^^^
 
@@ -631,7 +632,7 @@
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
     EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:3:29 error: access: index out of bounds for type vec2<f32>
+              R"(:3:29 error: access: index out of bounds for type 'vec2<f32>'
     %3:f32 = access %2, 1u, 3u
                             ^^
 
@@ -667,7 +668,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:3:55 error: access: index out of bounds for type ptr<private, array<f32, 2>, read_write>
+        R"(:3:55 error: access: index out of bounds for type 'ptr<private, array<f32, 2>, read_write>'
     %3:ptr<private, f32, read_write> = access %2, 1u, 3u
                                                       ^^
 
@@ -701,7 +702,7 @@
 
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
-    EXPECT_EQ(res.Failure().reason.Str(), R"(:3:25 error: access: type f32 cannot be indexed
+    EXPECT_EQ(res.Failure().reason.Str(), R"(:3:25 error: access: type 'f32' cannot be indexed
     %3:f32 = access %2, 1u
                         ^^
 
@@ -732,7 +733,7 @@
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
     EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:3:51 error: access: type ptr<private, f32, read_write> cannot be indexed
+              R"(:3:51 error: access: type 'ptr<private, f32, read_write>' cannot be indexed
     %3:ptr<private, f32, read_write> = access %2, 1u
                                                   ^^
 
@@ -769,7 +770,7 @@
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
     EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:8:25 error: access: type MyStruct cannot be dynamically indexed
+              R"(:8:25 error: access: type 'MyStruct' cannot be dynamically indexed
     %4:i32 = access %2, %3
                         ^^
 
@@ -812,7 +813,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:8:25 error: access: type ptr<private, MyStruct, read_write> cannot be dynamically indexed
+        R"(:8:25 error: access: type 'ptr<private, MyStruct, read_write>' cannot be dynamically indexed
     %4:i32 = access %2, %3
                         ^^
 
@@ -847,8 +848,9 @@
 
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
-    EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:3:14 error: access: result of access chain is type f32 but instruction type is i32
+    EXPECT_EQ(
+        res.Failure().reason.Str(),
+        R"(:3:14 error: access: result of access chain is type 'f32' but instruction type is 'i32'
     %3:i32 = access %2, 1u, 1u
              ^^^^^^
 
@@ -880,7 +882,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:3:40 error: access: result of access chain is type ptr<private, f32, read_write> but instruction type is ptr<private, i32, read_write>
+        R"(:3:40 error: access: result of access chain is type 'ptr<private, f32, read_write>' but instruction type is 'ptr<private, i32, read_write>'
     %3:ptr<private, i32, read_write> = access %2, 1u, 1u
                                        ^^^^^^
 
@@ -912,7 +914,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:3:14 error: access: result of access chain is type ptr<private, f32, read_write> but instruction type is f32
+        R"(:3:14 error: access: result of access chain is type 'ptr<private, f32, read_write>' but instruction type is 'f32'
     %3:f32 = access %2, 1u, 1u
              ^^^^^^
 
@@ -1034,7 +1036,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:3:34 error: access: result of access chain is type ptr<storage, f32, read> but instruction type is ptr<uniform, f32, read>
+        R"(:3:34 error: access: result of access chain is type 'ptr<storage, f32, read>' but instruction type is 'ptr<uniform, f32, read>'
     %3:ptr<uniform, f32, read> = access %2, 1u
                                  ^^^^^^
 
@@ -1066,7 +1068,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:3:40 error: access: result of access chain is type ptr<storage, f32, read> but instruction type is ptr<storage, f32, read_write>
+        R"(:3:40 error: access: result of access chain is type 'ptr<storage, f32, read>' but instruction type is 'ptr<storage, f32, read_write>'
     %3:ptr<storage, f32, read_write> = access %2, 1u
                                        ^^^^^^
 
@@ -1197,7 +1199,7 @@
 
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
-    EXPECT_EQ(res.Failure().reason.Str(), R"(:3:8 error: if: condition must be a `bool` type
+    EXPECT_EQ(res.Failure().reason.Str(), R"(:3:8 error: if: condition type must be 'bool'
     if 1i [t: $B2, f: $B3] {  # if_1
        ^^
 
@@ -1400,7 +1402,8 @@
 
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
-    EXPECT_EQ(res.Failure().reason.Str(), R"(:3:41 error: var: initializer has incorrect type
+    EXPECT_EQ(res.Failure().reason.Str(),
+              R"(:3:41 error: var: initializer type 'i32' does not match store type 'f32'
     %2:ptr<function, f32, read_write> = var, 1i
                                         ^^^
 
@@ -1488,7 +1491,8 @@
 
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
-    EXPECT_EQ(res.Failure().reason.Str(), R"(:3:14 error: let: result type does not match value type
+    EXPECT_EQ(res.Failure().reason.Str(),
+              R"(:3:14 error: let: result type 'f32' does not match value type 'i32'
     %2:f32 = let 1i
              ^^^
 
@@ -1813,7 +1817,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:3:5 error: unary: unary instruction result type (f32) does not match overload result type (i32)
+        R"(:3:5 error: unary: result value type 'f32' does not match complement result type 'i32'
     %2:f32 = complement 2i
     ^^^^^^^^^^^^^^^^^^^^^^
 
@@ -1998,7 +2002,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:5:21 error: exit_if: argument type (f32) does not match control instruction type (i32)
+        R"(:5:21 error: exit_if: argument type 'f32' does not match control instruction type 'i32'
         exit_if 1i, 2i  # if_1
                     ^^
 
@@ -2392,7 +2396,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:5:25 error: exit_switch: argument type (f32) does not match control instruction type (i32)
+        R"(:5:25 error: exit_switch: argument type 'f32' does not match control instruction type 'i32'
         exit_switch 1i, 2i  # switch_1
                         ^^
 
@@ -2780,7 +2784,7 @@
     ASSERT_NE(res, Success);
     EXPECT_EQ(
         res.Failure().reason.Str(),
-        R"(:5:23 error: exit_loop: argument type (f32) does not match control instruction type (i32)
+        R"(:5:23 error: exit_loop: argument type 'f32' does not match control instruction type 'i32'
         exit_loop 1i, 2i  # loop_1
                       ^^
 
@@ -3300,8 +3304,9 @@
 
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
-    EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:3:5 error: return: return value type does not match function return type
+    EXPECT_EQ(
+        res.Failure().reason.Str(),
+        R"(:3:5 error: return: return value type 'f32' does not match function return type 'i32'
     ret 42.0f
     ^^^^^^^^^
 
@@ -3392,7 +3397,7 @@
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
     EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:4:19 error: load: result type does not match source store type
+              R"(:4:19 error: load: result type 'f32' does not match source store type 'i32'
     %3:f32 = load %2
                   ^^
 
@@ -3512,7 +3517,7 @@
     auto res = ir::Validate(mod);
     ASSERT_NE(res, Success);
     EXPECT_EQ(res.Failure().reason.Str(),
-              R"(:4:15 error: store: value type does not match store type
+              R"(:4:15 error: store: value type 'u32' does not match store type 'i32'
     store %2, 42u
               ^^^
 
@@ -3672,7 +3677,7 @@
   $B1: {
   ^^^
 
-:4:37 error: store_vector_element: value type does not match vector pointer element type
+:4:37 error: store_vector_element: value type 'i32' does not match vector pointer element type 'f32'
     store_vector_element %2, undef, 2i
                                     ^^
 
diff --git a/src/tint/lang/core/ir/value_test.cc b/src/tint/lang/core/ir/value_test.cc
index 43fd900..c8b7954 100644
--- a/src/tint/lang/core/ir/value_test.cc
+++ b/src/tint/lang/core/ir/value_test.cc
@@ -66,7 +66,7 @@
 }
 
 TEST_F(IR_ValueTest, Destroy_HasSource) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/core/ir/var_test.cc b/src/tint/lang/core/ir/var_test.cc
index e1759d9..88a1c69 100644
--- a/src/tint/lang/core/ir/var_test.cc
+++ b/src/tint/lang/core/ir/var_test.cc
@@ -41,7 +41,7 @@
 using IR_VarTest = IRTestHelper;
 
 TEST_F(IR_VarTest, Fail_NullType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Module mod;
             Builder b{mod};
diff --git a/src/tint/lang/msl/validate/BUILD.bazel b/src/tint/lang/msl/validate/BUILD.bazel
index 7e5730a..2b76c9d 100644
--- a/src/tint/lang/msl/validate/BUILD.bazel
+++ b/src/tint/lang/msl/validate/BUILD.bazel
@@ -50,29 +50,9 @@
     "validate.h",
   ],
   deps = [
-    "//src/tint/lang/core",
-    "//src/tint/lang/core/constant",
-    "//src/tint/lang/core/type",
-    "//src/tint/lang/wgsl",
-    "//src/tint/lang/wgsl/ast",
-    "//src/tint/lang/wgsl/features",
-    "//src/tint/lang/wgsl/program",
-    "//src/tint/lang/wgsl/sem",
     "//src/tint/utils/command",
-    "//src/tint/utils/containers",
-    "//src/tint/utils/diagnostic",
     "//src/tint/utils/file",
-    "//src/tint/utils/ice",
-    "//src/tint/utils/id",
-    "//src/tint/utils/macros",
-    "//src/tint/utils/math",
-    "//src/tint/utils/memory",
-    "//src/tint/utils/reflection",
-    "//src/tint/utils/result",
-    "//src/tint/utils/rtti",
-    "//src/tint/utils/symbol",
     "//src/tint/utils/text",
-    "//src/tint/utils/traits",
   ] + select({
     ":tint_build_is_mac": [
       
diff --git a/src/tint/lang/msl/validate/BUILD.cmake b/src/tint/lang/msl/validate/BUILD.cmake
index 698e899..3fcdcac 100644
--- a/src/tint/lang/msl/validate/BUILD.cmake
+++ b/src/tint/lang/msl/validate/BUILD.cmake
@@ -46,29 +46,9 @@
 )
 
 tint_target_add_dependencies(tint_lang_msl_validate lib
-  tint_lang_core
-  tint_lang_core_constant
-  tint_lang_core_type
-  tint_lang_wgsl
-  tint_lang_wgsl_ast
-  tint_lang_wgsl_features
-  tint_lang_wgsl_program
-  tint_lang_wgsl_sem
   tint_utils_command
-  tint_utils_containers
-  tint_utils_diagnostic
   tint_utils_file
-  tint_utils_ice
-  tint_utils_id
-  tint_utils_macros
-  tint_utils_math
-  tint_utils_memory
-  tint_utils_reflection
-  tint_utils_result
-  tint_utils_rtti
-  tint_utils_symbol
   tint_utils_text
-  tint_utils_traits
 )
 
 if(TINT_BUILD_IS_MAC)
diff --git a/src/tint/lang/msl/validate/BUILD.gn b/src/tint/lang/msl/validate/BUILD.gn
index cbf7c61..902ed8d 100644
--- a/src/tint/lang/msl/validate/BUILD.gn
+++ b/src/tint/lang/msl/validate/BUILD.gn
@@ -44,29 +44,9 @@
       "validate.h",
     ]
     deps = [
-      "${tint_src_dir}/lang/core",
-      "${tint_src_dir}/lang/core/constant",
-      "${tint_src_dir}/lang/core/type",
-      "${tint_src_dir}/lang/wgsl",
-      "${tint_src_dir}/lang/wgsl/ast",
-      "${tint_src_dir}/lang/wgsl/features",
-      "${tint_src_dir}/lang/wgsl/program",
-      "${tint_src_dir}/lang/wgsl/sem",
       "${tint_src_dir}/utils/command",
-      "${tint_src_dir}/utils/containers",
-      "${tint_src_dir}/utils/diagnostic",
       "${tint_src_dir}/utils/file",
-      "${tint_src_dir}/utils/ice",
-      "${tint_src_dir}/utils/id",
-      "${tint_src_dir}/utils/macros",
-      "${tint_src_dir}/utils/math",
-      "${tint_src_dir}/utils/memory",
-      "${tint_src_dir}/utils/reflection",
-      "${tint_src_dir}/utils/result",
-      "${tint_src_dir}/utils/rtti",
-      "${tint_src_dir}/utils/symbol",
       "${tint_src_dir}/utils/text",
-      "${tint_src_dir}/utils/traits",
     ]
 
     if (tint_build_is_mac) {
diff --git a/src/tint/lang/msl/validate/validate.cc b/src/tint/lang/msl/validate/validate.cc
index 2230f54..0c960ed 100644
--- a/src/tint/lang/msl/validate/validate.cc
+++ b/src/tint/lang/msl/validate/validate.cc
@@ -27,8 +27,6 @@
 
 #include "src/tint/lang/msl/validate/validate.h"
 
-#include "src/tint/lang/wgsl/ast/module.h"
-#include "src/tint/lang/wgsl/program/program.h"
 #include "src/tint/utils/command/command.h"
 #include "src/tint/utils/file/tmpfile.h"
 
diff --git a/src/tint/lang/msl/validate/validate.h b/src/tint/lang/msl/validate/validate.h
index d7b8acf..df2108a 100644
--- a/src/tint/lang/msl/validate/validate.h
+++ b/src/tint/lang/msl/validate/validate.h
@@ -31,13 +31,6 @@
 #include <string>
 #include <utility>
 
-#include "src/tint/lang/wgsl/ast/pipeline_stage.h"
-
-// Forward declarations
-namespace tint {
-class Program;
-}  // namespace tint
-
 namespace tint::msl::validate {
 
 /// The version of MSL to validate against.
diff --git a/src/tint/lang/msl/writer/raise/builtin_polyfill.cc b/src/tint/lang/msl/writer/raise/builtin_polyfill.cc
index e41330f..e44d1a6 100644
--- a/src/tint/lang/msl/writer/raise/builtin_polyfill.cc
+++ b/src/tint/lang/msl/writer/raise/builtin_polyfill.cc
@@ -73,42 +73,32 @@
 
         // Replace the builtins that we found.
         for (auto* builtin : worklist) {
-            core::ir::Value* replacement = nullptr;
             switch (builtin->Func()) {
                 case core::BuiltinFn::kStorageBarrier:
-                    replacement = ThreadgroupBarrier(builtin, BarrierType::kDevice);
+                    ThreadgroupBarrier(builtin, BarrierType::kDevice);
                     break;
                 case core::BuiltinFn::kWorkgroupBarrier:
-                    replacement = ThreadgroupBarrier(builtin, BarrierType::kThreadGroup);
+                    ThreadgroupBarrier(builtin, BarrierType::kThreadGroup);
                     break;
                 case core::BuiltinFn::kTextureBarrier:
-                    replacement = ThreadgroupBarrier(builtin, BarrierType::kTexture);
+                    ThreadgroupBarrier(builtin, BarrierType::kTexture);
                     break;
                 default:
                     break;
             }
-            TINT_ASSERT(replacement);
-
-            // Replace the old builtin result with the new value.
-            if (auto name = ir.NameOf(builtin->Result(0))) {
-                ir.SetName(replacement, name);
-            }
-            builtin->Result(0)->ReplaceAllUsesWith(replacement);
-            builtin->Destroy();
         }
     }
 
     /// Replace a barrier builtin with the `threadgroupBarrier()` intrinsic.
     /// @param builtin the builtin call instruction
     /// @param type the barrier type
-    /// @returns the replacement value
-    core::ir::Value* ThreadgroupBarrier(core::ir::CoreBuiltinCall* builtin, BarrierType type) {
+    void ThreadgroupBarrier(core::ir::CoreBuiltinCall* builtin, BarrierType type) {
         // Replace the builtin call with a call to the msl.threadgroup_barrier intrinsic.
         auto args = Vector<core::ir::Value*, 1>{b.Constant(u32(type))};
-        auto* call = b.Call<msl::ir::BuiltinCall>(
-            builtin->Result(0)->Type(), msl::BuiltinFn::kThreadgroupBarrier, std::move(args));
+        auto* call = b.CallWithResult<msl::ir::BuiltinCall>(
+            builtin->DetachResult(), msl::BuiltinFn::kThreadgroupBarrier, std::move(args));
         call->InsertBefore(builtin);
-        return call->Result(0);
+        builtin->Destroy();
     }
 };
 
diff --git a/src/tint/lang/spirv/reader/lower/BUILD.bazel b/src/tint/lang/spirv/reader/lower/BUILD.bazel
index 01fd572..1348c8d 100644
--- a/src/tint/lang/spirv/reader/lower/BUILD.bazel
+++ b/src/tint/lang/spirv/reader/lower/BUILD.bazel
@@ -40,10 +40,12 @@
   name = "lower",
   srcs = [
     "lower.cc",
+    "shader_io.cc",
     "vector_element_pointer.cc",
   ],
   hdrs = [
     "lower.h",
+    "shader_io.h",
     "vector_element_pointer.h",
   ],
   deps = [
@@ -52,6 +54,7 @@
     "//src/tint/lang/core/constant",
     "//src/tint/lang/core/intrinsic",
     "//src/tint/lang/core/ir",
+    "//src/tint/lang/core/ir/transform/common",
     "//src/tint/lang/core/type",
     "//src/tint/utils/containers",
     "//src/tint/utils/diagnostic",
@@ -74,6 +77,7 @@
   name = "test",
   alwayslink = True,
   srcs = [
+    "shader_io_test.cc",
     "vector_element_pointer_test.cc",
   ],
   deps = [
diff --git a/src/tint/lang/spirv/reader/lower/BUILD.cmake b/src/tint/lang/spirv/reader/lower/BUILD.cmake
index 5f0e866..98de660 100644
--- a/src/tint/lang/spirv/reader/lower/BUILD.cmake
+++ b/src/tint/lang/spirv/reader/lower/BUILD.cmake
@@ -41,6 +41,8 @@
 tint_add_target(tint_lang_spirv_reader_lower lib
   lang/spirv/reader/lower/lower.cc
   lang/spirv/reader/lower/lower.h
+  lang/spirv/reader/lower/shader_io.cc
+  lang/spirv/reader/lower/shader_io.h
   lang/spirv/reader/lower/vector_element_pointer.cc
   lang/spirv/reader/lower/vector_element_pointer.h
 )
@@ -51,6 +53,7 @@
   tint_lang_core_constant
   tint_lang_core_intrinsic
   tint_lang_core_ir
+  tint_lang_core_ir_transform_common
   tint_lang_core_type
   tint_utils_containers
   tint_utils_diagnostic
@@ -72,6 +75,7 @@
 # Kind:      test
 ################################################################################
 tint_add_target(tint_lang_spirv_reader_lower_test test
+  lang/spirv/reader/lower/shader_io_test.cc
   lang/spirv/reader/lower/vector_element_pointer_test.cc
 )
 
diff --git a/src/tint/lang/spirv/reader/lower/BUILD.gn b/src/tint/lang/spirv/reader/lower/BUILD.gn
index b5f0342..37c4709 100644
--- a/src/tint/lang/spirv/reader/lower/BUILD.gn
+++ b/src/tint/lang/spirv/reader/lower/BUILD.gn
@@ -46,6 +46,8 @@
   sources = [
     "lower.cc",
     "lower.h",
+    "shader_io.cc",
+    "shader_io.h",
     "vector_element_pointer.cc",
     "vector_element_pointer.h",
   ]
@@ -55,6 +57,7 @@
     "${tint_src_dir}/lang/core/constant",
     "${tint_src_dir}/lang/core/intrinsic",
     "${tint_src_dir}/lang/core/ir",
+    "${tint_src_dir}/lang/core/ir/transform/common",
     "${tint_src_dir}/lang/core/type",
     "${tint_src_dir}/utils/containers",
     "${tint_src_dir}/utils/diagnostic",
@@ -73,7 +76,10 @@
 }
 if (tint_build_unittests) {
   tint_unittests_source_set("unittests") {
-    sources = [ "vector_element_pointer_test.cc" ]
+    sources = [
+      "shader_io_test.cc",
+      "vector_element_pointer_test.cc",
+    ]
     deps = [
       "${tint_src_dir}:gmock_and_gtest",
       "${tint_src_dir}/api/common",
diff --git a/src/tint/lang/spirv/reader/lower/lower.cc b/src/tint/lang/spirv/reader/lower/lower.cc
index 25f546c..433d0cd 100644
--- a/src/tint/lang/spirv/reader/lower/lower.cc
+++ b/src/tint/lang/spirv/reader/lower/lower.cc
@@ -28,6 +28,7 @@
 #include "src/tint/lang/spirv/reader/lower/lower.h"
 
 #include "src/tint/lang/core/ir/validator.h"
+#include "src/tint/lang/spirv/reader/lower/shader_io.h"
 #include "src/tint/lang/spirv/reader/lower/vector_element_pointer.h"
 
 namespace tint::spirv::reader {
@@ -42,6 +43,7 @@
     } while (false)
 
     RUN_TRANSFORM(lower::VectorElementPointer, mod);
+    RUN_TRANSFORM(lower::ShaderIO, mod);
 
     if (auto res = core::ir::ValidateAndDumpIfNeeded(mod, "end of lowering from SPIR-V");
         res != Success) {
diff --git a/src/tint/lang/spirv/reader/lower/shader_io.cc b/src/tint/lang/spirv/reader/lower/shader_io.cc
new file mode 100644
index 0000000..a786791
--- /dev/null
+++ b/src/tint/lang/spirv/reader/lower/shader_io.cc
@@ -0,0 +1,426 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/spirv/reader/lower/shader_io.h"
+
+#include <utility>
+
+#include "src/tint/lang/core/ir/builder.h"
+#include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/transform/common/referenced_module_vars.h"
+#include "src/tint/lang/core/ir/validator.h"
+
+namespace tint::spirv::reader::lower {
+
+namespace {
+
+using namespace tint::core::fluent_types;  // NOLINT
+
+/// PIMPL state for the transform.
+struct State {
+    /// The IR module.
+    core::ir::Module& ir;
+
+    /// The IR builder.
+    core::ir::Builder b{ir};
+
+    /// The type manager.
+    core::type::Manager& ty{ir.Types()};
+
+    /// A map from block to its containing function.
+    Hashmap<core::ir::Block*, core::ir::Function*, 64> block_to_function{};
+
+    /// A map from each function to a map from input variable to parameter.
+    Hashmap<core::ir::Function*, Hashmap<core::ir::Var*, core::ir::Value*, 4>, 8>
+        function_parameter_map{};
+
+    /// The set of output variables that have been processed.
+    Hashset<core::ir::Var*, 4> output_variables{};
+
+    /// The mapping from functions to their transitively referenced output variables.
+    core::ir::ReferencedModuleVars referenced_output_vars{
+        ir, [](const core::ir::Var* var) {
+            auto* view = var->Result(0)->Type()->As<core::type::MemoryView>();
+            return view && view->AddressSpace() == core::AddressSpace::kOut;
+        }};
+
+    /// Process the module.
+    void Process() {
+        // Process outputs first, as that may introduce new functions that input variables need to
+        // be propagated through.
+        ProcessOutputs();
+        ProcessInputs();
+    }
+
+    /// Process output variables.
+    /// Changes output variables to the `private` address space and wraps entry points that produce
+    /// outputs with new functions that copy the outputs from the private variables to the return
+    /// value.
+    void ProcessOutputs() {
+        // Update entry point functions to return their outputs, using a wrapper function.
+        // Use a worklist as `ProcessEntryPointOutputs()` will add new functions.
+        Vector<core::ir::Function*, 4> entry_points;
+        for (auto& func : ir.functions) {
+            if (func->Stage() != core::ir::Function::PipelineStage::kUndefined) {
+                entry_points.Push(func);
+            }
+        }
+        for (auto& ep : entry_points) {
+            ProcessEntryPointOutputs(ep);
+        }
+
+        // Remove attributes from all of the original structs and module-scope output variables.
+        // This is done last as we need to copy attributes during `ProcessEntryPointOutputs()`.
+        for (auto& var : output_variables) {
+            var->SetAttributes({});
+            if (auto* str = var->Result(0)->Type()->UnwrapPtr()->As<core::type::Struct>()) {
+                for (auto* member : str->Members()) {
+                    // TODO(crbug.com/tint/745): Remove the const_cast.
+                    const_cast<core::type::StructMember*>(member)->SetAttributes({});
+                }
+            }
+        }
+    }
+
+    /// Process input variables.
+    /// Pass inputs down the call stack as parameters to any functions that need them.
+    void ProcessInputs() {
+        // Seed the block-to-function map with the function entry blocks.
+        for (auto& func : ir.functions) {
+            block_to_function.Add(func->Block(), func);
+        }
+
+        // Gather the list of all module-scope input variables.
+        Vector<core::ir::Var*, 4> inputs;
+        for (auto* global : *ir.root_block) {
+            if (auto* var = global->As<core::ir::Var>()) {
+                auto addrspace = var->Result(0)->Type()->As<core::type::Pointer>()->AddressSpace();
+                if (addrspace == core::AddressSpace::kIn) {
+                    inputs.Push(var);
+                }
+            }
+        }
+
+        // Replace the input variables with function parameters.
+        for (auto* var : inputs) {
+            ReplaceInputPointerUses(var, var->Result(0));
+            var->Destroy();
+        }
+    }
+
+    /// Replace an output pointer address space to make it `private`.
+    /// @param value the output variable
+    void ReplaceOutputPointerAddressSpace(core::ir::InstructionResult* value) {
+        // Change the address space to `private`.
+        auto* old_ptr_type = value->Type();
+        auto* new_ptr_type = ty.ptr(core::AddressSpace::kPrivate, old_ptr_type->UnwrapPtr());
+        value->SetType(new_ptr_type);
+
+        // Update all uses of the module-scope variable.
+        value->ForEachUse([&](core::ir::Usage use) {
+            if (auto* access = use.instruction->As<core::ir::Access>()) {
+                ReplaceOutputPointerAddressSpace(access->Result(0));
+            } else if (!use.instruction->IsAnyOf<core::ir::Load, core::ir::LoadVectorElement,
+                                                 core::ir::Store, core::ir::StoreVectorElement>()) {
+                TINT_UNREACHABLE()
+                    << "unexpected instruction: " << use.instruction->TypeInfo().name;
+            }
+        });
+    }
+
+    /// Process the outputs of an entry point function, adding a wrapper function to forward outputs
+    /// through the return value.
+    /// @param ep the entry point
+    void ProcessEntryPointOutputs(core::ir::Function* ep) {
+        const auto& referenced_outputs = referenced_output_vars.TransitiveReferences(ep);
+        if (referenced_outputs.IsEmpty()) {
+            return;
+        }
+
+        // Add a wrapper function to return either a single value or a struct.
+        auto* wrapper = b.Function(ty.void_(), ep->Stage());
+        if (auto name = ir.NameOf(ep)) {
+            ir.SetName(ep, name.Name() + "_inner");
+            ir.SetName(wrapper, name);
+        }
+
+        // Call the original entry point and make it a regular function.
+        ep->SetStage(core::ir::Function::PipelineStage::kUndefined);
+        b.Append(wrapper->Block(), [&] {  //
+            b.Call(ep);
+        });
+
+        // Collect all outputs into a list of struct member declarations.
+        // Also add instructions to load their final values in the wrapper function.
+        Vector<core::ir::Value*, 4> results;
+        Vector<core::type::Manager::StructMemberDesc, 4> output_descriptors;
+        auto add_output = [&](Symbol name, const core::type::Type* type,
+                              core::type::StructMemberAttributes attributes) {
+            if (!name) {
+                name = ir.symbols.New();
+            }
+            output_descriptors.Push(core::type::Manager::StructMemberDesc{name, type, attributes});
+        };
+        for (auto* var : referenced_outputs) {
+            // Change the address space of the variable to private and update its uses, if we
+            // haven't already seen this variable.
+            if (output_variables.Add(var)) {
+                ReplaceOutputPointerAddressSpace(var->Result(0));
+            }
+
+            // Copy the variable attributes to the struct member.
+            const auto& original_attributes = var->Attributes();
+            core::type::StructMemberAttributes var_attributes;
+            var_attributes.invariant = original_attributes.invariant;
+            var_attributes.builtin = original_attributes.builtin;
+            var_attributes.location = original_attributes.location;
+            var_attributes.interpolation = original_attributes.interpolation;
+
+            auto var_type = var->Result(0)->Type()->UnwrapPtr();
+            if (auto* str = var_type->As<core::type::Struct>()) {
+                // Add an output for each member of the struct.
+                for (auto* member : str->Members()) {
+                    // Use the base variable attributes if not specified directly on the member.
+                    auto member_attributes = member->Attributes();
+                    if (auto base_loc = var_attributes.location) {
+                        // Location values increment from the base location value on the variable.
+                        member_attributes.location = base_loc.value() + member->Index();
+                    }
+                    if (!member_attributes.interpolation) {
+                        member_attributes.interpolation = var_attributes.interpolation;
+                    }
+
+                    add_output(member->Name(), member->Type(), std::move(member_attributes));
+
+                    // Load the final result from the member of the original struct variable.
+                    b.Append(wrapper->Block(), [&] {  //
+                        auto* access =
+                            b.Access(ty.ptr<private_>(member->Type()), var, u32(member->Index()));
+                        results.Push(b.Load(access)->Result(0));
+                    });
+                }
+            } else {
+                // Load the final result from the original variable.
+                b.Append(wrapper->Block(), [&] {
+                    results.Push(b.Load(var)->Result(0));
+
+                    // If we're dealing with sample_mask, extract the scalar from the array.
+                    if (var_attributes.builtin == core::BuiltinValue::kSampleMask) {
+                        var_type = ty.u32();
+                        results.Back() = b.Access(ty.u32(), results.Back(), u32(0))->Result(0);
+                    }
+                });
+                add_output(ir.NameOf(var), var_type, std::move(var_attributes));
+            }
+        }
+
+        if (output_descriptors.Length() == 1) {
+            // Copy the output attributes to the function return.
+            const auto& attributes = output_descriptors[0].attributes;
+            wrapper->SetReturnInvariant(attributes.invariant);
+            if (attributes.builtin) {
+                wrapper->SetReturnBuiltin(attributes.builtin.value());
+            } else if (attributes.location) {
+                core::ir::Location loc;
+                loc.value = attributes.location.value();
+                loc.interpolation = attributes.interpolation;
+                wrapper->SetReturnLocation(std::move(loc));
+            }
+
+            // Return the output from the wrapper function.
+            wrapper->SetReturnType(output_descriptors[0].type);
+            b.Append(wrapper->Block(), [&] {  //
+                b.Return(wrapper, results[0]);
+            });
+        } else {
+            // Create a struct to hold all of the output values.
+            auto* str = ty.Struct(ir.symbols.New(), std::move(output_descriptors));
+            wrapper->SetReturnType(str);
+
+            // Collect the output values and return them from the wrapper function.
+            b.Append(wrapper->Block(), [&] {  //
+                b.Return(wrapper, b.Construct(str, std::move(results)));
+            });
+        }
+    }
+
+    /// Replace a use of an input pointer value.
+    /// @param var the originating input variable
+    /// @param value the input pointer value
+    void ReplaceInputPointerUses(core::ir::Var* var, core::ir::Value* value) {
+        Vector<core::ir::Instruction*, 8> to_destroy;
+        value->ForEachUse([&](core::ir::Usage use) {
+            auto* object = value;
+            if (object->Type()->Is<core::type::Pointer>()) {
+                // Get (or create) the function parameter that will replace the variable.
+                auto* func = ContainingFunction(use.instruction);
+                object = GetParameter(func, var);
+            }
+
+            Switch(
+                use.instruction,
+                [&](core::ir::Load* l) {
+                    // Fold the load away and replace its uses with the new parameter.
+                    l->Result(0)->ReplaceAllUsesWith(object);
+                    to_destroy.Push(l);
+                },
+                [&](core::ir::LoadVectorElement* lve) {
+                    // Replace the vector element load with an access instruction.
+                    auto* access = b.AccessWithResult(lve->DetachResult(), object, lve->Index());
+                    access->InsertBefore(lve);
+                    to_destroy.Push(lve);
+                },
+                [&](core::ir::Access* a) {
+                    if (!a->Indices().IsEmpty()) {
+                        // Remove the pointer from the source and destination type.
+                        a->SetOperand(core::ir::Access::kObjectOperandOffset, object);
+                        a->Result(0)->SetType(a->Result(0)->Type()->UnwrapPtr());
+                        ReplaceInputPointerUses(var, a->Result(0));
+                    } else {
+                        // Fold the access away and replace its uses.
+                        ReplaceInputPointerUses(var, a->Result(0));
+                        to_destroy.Push(a);
+                    }
+                },
+                TINT_ICE_ON_NO_MATCH);
+        });
+
+        // Clean up orphaned instructions.
+        for (auto* inst : to_destroy) {
+            inst->Destroy();
+        }
+    }
+
+    /// Get the function that contains an instruction.
+    /// @param inst the instruction
+    /// @returns the function
+    core::ir::Function* ContainingFunction(core::ir::Instruction* inst) {
+        return block_to_function.GetOrAdd(inst->Block(), [&] {  //
+            return ContainingFunction(inst->Block()->Parent());
+        });
+    }
+
+    /// Get or create a function parameter to replace a module-scope variable.
+    /// @param func the function
+    /// @param var the module-scope variable
+    /// @returns the function parameter
+    core::ir::Value* GetParameter(core::ir::Function* func, core::ir::Var* var) {
+        return function_parameter_map.GetOrAddZero(func).GetOrAdd(var, [&] {
+            const bool entry_point = func->Stage() != core::ir::Function::PipelineStage::kUndefined;
+            auto* var_type = var->Result(0)->Type()->UnwrapPtr();
+
+            // Use a scalar u32 for sample_mask builtins for entry point parameters.
+            if (entry_point && var->Attributes().builtin == core::BuiltinValue::kSampleMask) {
+                TINT_ASSERT(var_type->Is<core::type::Array>());
+                TINT_ASSERT(var_type->As<core::type::Array>()->ConstantCount() == 1u);
+                var_type = ty.u32();
+            }
+
+            // Create a new function parameter for the input.
+            auto* param = b.FunctionParam(var_type);
+            func->AppendParam(param);
+            if (auto name = ir.NameOf(var)) {
+                ir.SetName(param, name);
+            }
+
+            // Add attributes to the parameter if this is an entry point function.
+            if (entry_point) {
+                AddEntryPointParameterAttributes(param, var->Attributes());
+            }
+
+            // Update the callsites of this function.
+            func->ForEachUse([&](core::ir::Usage use) {
+                if (auto* call = use.instruction->As<core::ir::UserCall>()) {
+                    // Recurse into the calling function.
+                    auto* caller = ContainingFunction(call);
+                    call->AppendArg(GetParameter(caller, var));
+                } else if (!use.instruction->Is<core::ir::Return>()) {
+                    TINT_UNREACHABLE()
+                        << "unexpected instruction: " << use.instruction->TypeInfo().name;
+                }
+            });
+
+            core::ir::Value* result = param;
+            if (entry_point && var->Attributes().builtin == core::BuiltinValue::kSampleMask) {
+                // Construct an array from the scalar sample_mask builtin value for entry points.
+                b.Prepend(func->Block(), [&] {  //
+                    result = b.Construct(var->Result(0)->Type()->UnwrapPtr(), param)->Result(0);
+                });
+            }
+            return result;
+        });
+    }
+
+    /// Add attributes to an entry point function parameter.
+    /// @param param the parameter
+    /// @param attributes the attributes
+    void AddEntryPointParameterAttributes(core::ir::FunctionParam* param,
+                                          const core::ir::IOAttributes& attributes) {
+        if (auto* str = param->Type()->UnwrapPtr()->As<core::type::Struct>()) {
+            for (auto* member : str->Members()) {
+                // Use the base variable attributes if not specified directly on the member.
+                auto member_attributes = member->Attributes();
+                if (auto base_loc = attributes.location) {
+                    // Location values increment from the base location value on the variable.
+                    member_attributes.location = base_loc.value() + member->Index();
+                }
+                if (!member_attributes.interpolation) {
+                    member_attributes.interpolation = attributes.interpolation;
+                }
+                // TODO(crbug.com/tint/745): Remove the const_cast.
+                const_cast<core::type::StructMember*>(member)->SetAttributes(
+                    std::move(member_attributes));
+            }
+        } else {
+            // Set attributes directly on the function parameter.
+            param->SetInvariant(attributes.invariant);
+            if (attributes.builtin) {
+                param->SetBuiltin(attributes.builtin.value());
+            } else if (attributes.location) {
+                core::ir::Location loc;
+                loc.value = attributes.location.value();
+                loc.interpolation = attributes.interpolation;
+                param->SetLocation(std::move(loc));
+            }
+        }
+    }
+};
+
+}  // namespace
+
+Result<SuccessType> ShaderIO(core::ir::Module& ir) {
+    auto result = ValidateAndDumpIfNeeded(ir, "ShaderIO transform");
+    if (result != Success) {
+        return result.Failure();
+    }
+
+    State{ir}.Process();
+
+    return Success;
+}
+
+}  // namespace tint::spirv::reader::lower
diff --git a/src/tint/lang/spirv/reader/lower/shader_io.h b/src/tint/lang/spirv/reader/lower/shader_io.h
new file mode 100644
index 0000000..8347585
--- /dev/null
+++ b/src/tint/lang/spirv/reader/lower/shader_io.h
@@ -0,0 +1,48 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#ifndef SRC_TINT_LANG_SPIRV_READER_LOWER_SHADER_IO_H_
+#define SRC_TINT_LANG_SPIRV_READER_LOWER_SHADER_IO_H_
+
+#include "src/tint/utils/result/result.h"
+
+// Forward declarations.
+namespace tint::core::ir {
+class Module;
+}
+
+namespace tint::spirv::reader::lower {
+
+/// ShaderIO is a transform that converts SPIR-V's style of shader IO (using global variables) into
+/// the form expected by Tint's core IR (using function parameters and return values).
+/// @param module the module to transform
+/// @returns success or failure
+Result<SuccessType> ShaderIO(core::ir::Module& module);
+
+}  // namespace tint::spirv::reader::lower
+
+#endif  // SRC_TINT_LANG_SPIRV_READER_LOWER_SHADER_IO_H_
diff --git a/src/tint/lang/spirv/reader/lower/shader_io_test.cc b/src/tint/lang/spirv/reader/lower/shader_io_test.cc
new file mode 100644
index 0000000..d004762
--- /dev/null
+++ b/src/tint/lang/spirv/reader/lower/shader_io_test.cc
@@ -0,0 +1,2267 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/spirv/reader/lower/shader_io.h"
+
+#include <utility>
+
+#include "src/tint/lang/core/ir/transform/helper_test.h"
+
+namespace tint::spirv::reader::lower {
+namespace {
+
+using namespace tint::core::fluent_types;     // NOLINT
+using namespace tint::core::number_suffixes;  // NOLINT
+
+class SpirvReader_ShaderIOTest : public core::ir::transform::TransformTest {
+  protected:
+    core::type::StructMemberAttributes BuiltinAttrs(core::BuiltinValue builtin) {
+        core::type::StructMemberAttributes attrs;
+        attrs.builtin = builtin;
+        return attrs;
+    }
+    core::type::StructMemberAttributes LocationAttrs(
+        uint32_t location,
+        std::optional<core::Interpolation> interpolation = std::nullopt) {
+        core::type::StructMemberAttributes attrs;
+        attrs.location = location;
+        attrs.interpolation = interpolation;
+        return attrs;
+    }
+};
+
+TEST_F(SpirvReader_ShaderIOTest, NoInputsOrOutputs) {
+    auto* ep = b.Function("foo", ty.void_());
+    ep->SetStage(core::ir::Function::PipelineStage::kCompute);
+
+    b.Append(ep->Block(), [&] {  //
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+%foo = @compute func():void {
+  $B1: {
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = src;
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs) {
+    auto* front_facing = b.Var("front_facing", ty.ptr(core::AddressSpace::kIn, ty.bool_()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kFrontFacing;
+        front_facing->SetAttributes(std::move(attributes));
+    }
+    auto* position = b.Var("position", ty.ptr(core::AddressSpace::kIn, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kPosition;
+        attributes.invariant = true;
+        position->SetAttributes(std::move(attributes));
+    }
+    auto* color1 = b.Var("color1", ty.ptr(core::AddressSpace::kIn, ty.f32()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 0;
+        color1->SetAttributes(std::move(attributes));
+    }
+    auto* color2 = b.Var("color2", ty.ptr(core::AddressSpace::kIn, ty.f32()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1;
+        attributes.interpolation = core::Interpolation{core::InterpolationType::kLinear,
+                                                       core::InterpolationSampling::kSample};
+        color2->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(front_facing);
+    mod.root_block->Append(position);
+    mod.root_block->Append(color1);
+    mod.root_block->Append(color2);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        auto* ifelse = b.If(b.Load(front_facing));
+        b.Append(ifelse->True(), [&] {
+            auto* position_value = b.Load(position);
+            auto* color1_value = b.Load(color1);
+            auto* color2_value = b.Load(color2);
+            b.Multiply(ty.vec4<f32>(), position_value, b.Add(ty.f32(), color1_value, color2_value));
+            b.ExitIf(ifelse);
+        });
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %front_facing:ptr<__in, bool, read> = var @builtin(front_facing)
+  %position:ptr<__in, vec4<f32>, read> = var @invariant @builtin(position)
+  %color1:ptr<__in, f32, read> = var @location(0)
+  %color2:ptr<__in, f32, read> = var @location(1) @interpolate(linear, sample)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    %6:bool = load %front_facing
+    if %6 [t: $B3] {  # if_1
+      $B3: {  # true
+        %7:vec4<f32> = load %position
+        %8:f32 = load %color1
+        %9:f32 = load %color2
+        %10:f32 = add %8, %9
+        %11:vec4<f32> = mul %7, %10
+        exit_if  # if_1
+      }
+    }
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%foo = @fragment func(%front_facing:bool [@front_facing], %position:vec4<f32> [@invariant, @position], %color1:f32 [@location(0)], %color2:f32 [@location(1), @interpolate(linear, sample)]):void {
+  $B1: {
+    if %front_facing [t: $B2] {  # if_1
+      $B2: {  # true
+        %6:f32 = add %color1, %color2
+        %7:vec4<f32> = mul %position, %6
+        exit_if  # if_1
+      }
+    }
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_UsedByHelper) {
+    auto* front_facing = b.Var("front_facing", ty.ptr(core::AddressSpace::kIn, ty.bool_()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kFrontFacing;
+        front_facing->SetAttributes(std::move(attributes));
+    }
+    auto* position = b.Var("position", ty.ptr(core::AddressSpace::kIn, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kPosition;
+        attributes.invariant = true;
+        position->SetAttributes(std::move(attributes));
+    }
+    auto* color1 = b.Var("color1", ty.ptr(core::AddressSpace::kIn, ty.f32()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 0;
+        color1->SetAttributes(std::move(attributes));
+    }
+    auto* color2 = b.Var("color2", ty.ptr(core::AddressSpace::kIn, ty.f32()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1;
+        attributes.interpolation = core::Interpolation{core::InterpolationType::kLinear,
+                                                       core::InterpolationSampling::kSample};
+        color2->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(front_facing);
+    mod.root_block->Append(position);
+    mod.root_block->Append(color1);
+    mod.root_block->Append(color2);
+
+    // Inner function has an existing parameter.
+    auto* param = b.FunctionParam("existing_param", ty.f32());
+    auto* foo = b.Function("foo", ty.void_());
+    foo->SetParams({param});
+    b.Append(foo->Block(), [&] {
+        auto* ifelse = b.If(b.Load(front_facing));
+        b.Append(ifelse->True(), [&] {
+            auto* position_value = b.Load(position);
+            auto* color1_value = b.Load(color1);
+            auto* color2_value = b.Load(color2);
+            auto* add = b.Add(ty.f32(), color1_value, color2_value);
+            auto* mul = b.Multiply(ty.vec4<f32>(), position_value, add);
+            b.Divide(ty.vec4<f32>(), mul, param);
+            b.ExitIf(ifelse);
+        });
+        b.Return(foo);
+    });
+
+    // Intermediate function has no existing parameters.
+    auto* bar = b.Function("bar", ty.void_());
+    b.Append(bar->Block(), [&] {
+        b.Call(foo, 42_f);
+        b.Return(bar);
+    });
+
+    auto* ep = b.Function("main", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        b.Call(bar);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %front_facing:ptr<__in, bool, read> = var @builtin(front_facing)
+  %position:ptr<__in, vec4<f32>, read> = var @invariant @builtin(position)
+  %color1:ptr<__in, f32, read> = var @location(0)
+  %color2:ptr<__in, f32, read> = var @location(1) @interpolate(linear, sample)
+}
+
+%foo = func(%existing_param:f32):void {
+  $B2: {
+    %7:bool = load %front_facing
+    if %7 [t: $B3] {  # if_1
+      $B3: {  # true
+        %8:vec4<f32> = load %position
+        %9:f32 = load %color1
+        %10:f32 = load %color2
+        %11:f32 = add %9, %10
+        %12:vec4<f32> = mul %8, %11
+        %13:vec4<f32> = div %12, %existing_param
+        exit_if  # if_1
+      }
+    }
+    ret
+  }
+}
+%bar = func():void {
+  $B4: {
+    %15:void = call %foo, 42.0f
+    ret
+  }
+}
+%main = @fragment func():void {
+  $B5: {
+    %17:void = call %bar
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%foo = func(%existing_param:f32, %front_facing:bool, %position:vec4<f32>, %color1:f32, %color2:f32):void {
+  $B1: {
+    if %front_facing [t: $B2] {  # if_1
+      $B2: {  # true
+        %7:f32 = add %color1, %color2
+        %8:vec4<f32> = mul %position, %7
+        %9:vec4<f32> = div %8, %existing_param
+        exit_if  # if_1
+      }
+    }
+    ret
+  }
+}
+%bar = func(%front_facing_1:bool, %position_1:vec4<f32>, %color1_1:f32, %color2_1:f32):void {  # %front_facing_1: 'front_facing', %position_1: 'position', %color1_1: 'color1', %color2_1: 'color2'
+  $B3: {
+    %15:void = call %foo, 42.0f, %front_facing_1, %position_1, %color1_1, %color2_1
+    ret
+  }
+}
+%main = @fragment func(%front_facing_2:bool [@front_facing], %position_2:vec4<f32> [@invariant, @position], %color1_2:f32 [@location(0)], %color2_2:f32 [@location(1), @interpolate(linear, sample)]):void {  # %front_facing_2: 'front_facing', %position_2: 'position', %color1_2: 'color1', %color2_2: 'color2'
+  $B4: {
+    %21:void = call %bar, %front_facing_2, %position_2, %color1_2, %color2_2
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_UsedEntryPointAndHelper) {
+    auto* gid = b.Var("gid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kGlobalInvocationId;
+        gid->SetAttributes(std::move(attributes));
+    }
+    auto* lid = b.Var("lid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kLocalInvocationId;
+        lid->SetAttributes(std::move(attributes));
+    }
+    auto* group_id = b.Var("group_id", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kWorkgroupId;
+        group_id->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(gid);
+    mod.root_block->Append(lid);
+    mod.root_block->Append(group_id);
+
+    // Use a subset of the inputs in the helper.
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {
+        auto* gid_value = b.Load(gid);
+        auto* lid_value = b.Load(lid);
+        b.Add(ty.vec3<u32>(), gid_value, lid_value);
+        b.Return(foo);
+    });
+
+    // Use a different subset of the inputs in the entry point.
+    auto* ep = b.Function("main1", ty.void_(), core::ir::Function::PipelineStage::kCompute);
+    b.Append(ep->Block(), [&] {
+        auto* group_value = b.Load(group_id);
+        auto* gid_value = b.Load(gid);
+        b.Add(ty.vec3<u32>(), group_value, gid_value);
+        b.Call(foo);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %gid:ptr<__in, vec3<u32>, read> = var @builtin(global_invocation_id)
+  %lid:ptr<__in, vec3<u32>, read> = var @builtin(local_invocation_id)
+  %group_id:ptr<__in, vec3<u32>, read> = var @builtin(workgroup_id)
+}
+
+%foo = func():void {
+  $B2: {
+    %5:vec3<u32> = load %gid
+    %6:vec3<u32> = load %lid
+    %7:vec3<u32> = add %5, %6
+    ret
+  }
+}
+%main1 = @compute func():void {
+  $B3: {
+    %9:vec3<u32> = load %group_id
+    %10:vec3<u32> = load %gid
+    %11:vec3<u32> = add %9, %10
+    %12:void = call %foo
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%foo = func(%gid:vec3<u32>, %lid:vec3<u32>):void {
+  $B1: {
+    %4:vec3<u32> = add %gid, %lid
+    ret
+  }
+}
+%main1 = @compute func(%gid_1:vec3<u32> [@global_invocation_id], %lid_1:vec3<u32> [@local_invocation_id], %group_id:vec3<u32> [@workgroup_id]):void {  # %gid_1: 'gid', %lid_1: 'lid'
+  $B2: {
+    %9:vec3<u32> = add %group_id, %gid_1
+    %10:void = call %foo, %gid_1, %lid_1
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_UsedEntryPointAndHelper_ForwardReference) {
+    auto* gid = b.Var("gid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kGlobalInvocationId;
+        gid->SetAttributes(std::move(attributes));
+    }
+    auto* lid = b.Var("lid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kLocalInvocationId;
+        lid->SetAttributes(std::move(attributes));
+    }
+    auto* group_id = b.Var("group_id", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kWorkgroupId;
+        group_id->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(gid);
+    mod.root_block->Append(lid);
+    mod.root_block->Append(group_id);
+
+    auto* ep = b.Function("main1", ty.void_(), core::ir::Function::PipelineStage::kCompute);
+    auto* foo = b.Function("foo", ty.void_());
+
+    // Use a subset of the inputs in the entry point.
+    b.Append(ep->Block(), [&] {
+        auto* group_value = b.Load(group_id);
+        auto* gid_value = b.Load(gid);
+        b.Add(ty.vec3<u32>(), group_value, gid_value);
+        b.Call(foo);
+        b.Return(ep);
+    });
+
+    // Use a different subset of the variables in the helper.
+    b.Append(foo->Block(), [&] {
+        auto* gid_value = b.Load(gid);
+        auto* lid_value = b.Load(lid);
+        b.Add(ty.vec3<u32>(), gid_value, lid_value);
+        b.Return(foo);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %gid:ptr<__in, vec3<u32>, read> = var @builtin(global_invocation_id)
+  %lid:ptr<__in, vec3<u32>, read> = var @builtin(local_invocation_id)
+  %group_id:ptr<__in, vec3<u32>, read> = var @builtin(workgroup_id)
+}
+
+%main1 = @compute func():void {
+  $B2: {
+    %5:vec3<u32> = load %group_id
+    %6:vec3<u32> = load %gid
+    %7:vec3<u32> = add %5, %6
+    %8:void = call %foo
+    ret
+  }
+}
+%foo = func():void {
+  $B3: {
+    %10:vec3<u32> = load %gid
+    %11:vec3<u32> = load %lid
+    %12:vec3<u32> = add %10, %11
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%main1 = @compute func(%gid:vec3<u32> [@global_invocation_id], %lid:vec3<u32> [@local_invocation_id], %group_id:vec3<u32> [@workgroup_id]):void {
+  $B1: {
+    %5:vec3<u32> = add %group_id, %gid
+    %6:void = call %foo, %gid, %lid
+    ret
+  }
+}
+%foo = func(%gid_1:vec3<u32>, %lid_1:vec3<u32>):void {  # %gid_1: 'gid', %lid_1: 'lid'
+  $B2: {
+    %10:vec3<u32> = add %gid_1, %lid_1
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_UsedByMultipleEntryPoints) {
+    auto* gid = b.Var("gid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kGlobalInvocationId;
+        gid->SetAttributes(std::move(attributes));
+    }
+    auto* lid = b.Var("lid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kLocalInvocationId;
+        lid->SetAttributes(std::move(attributes));
+    }
+    auto* group_id = b.Var("group_id", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kWorkgroupId;
+        group_id->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(gid);
+    mod.root_block->Append(lid);
+    mod.root_block->Append(group_id);
+
+    // Use a subset of the inputs in the helper.
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {
+        auto* gid_value = b.Load(gid);
+        auto* lid_value = b.Load(lid);
+        b.Add(ty.vec3<u32>(), gid_value, lid_value);
+        b.Return(foo);
+    });
+
+    // Call the helper without directly referencing any inputs.
+    auto* ep1 = b.Function("main1", ty.void_(), core::ir::Function::PipelineStage::kCompute);
+    b.Append(ep1->Block(), [&] {
+        b.Call(foo);
+        b.Return(ep1);
+    });
+
+    // Reference another input and then call the helper.
+    auto* ep2 = b.Function("main2", ty.void_(), core::ir::Function::PipelineStage::kCompute);
+    b.Append(ep2->Block(), [&] {
+        auto* group_value = b.Load(group_id);
+        b.Add(ty.vec3<u32>(), group_value, group_value);
+        b.Call(foo);
+        b.Return(ep1);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %gid:ptr<__in, vec3<u32>, read> = var @builtin(global_invocation_id)
+  %lid:ptr<__in, vec3<u32>, read> = var @builtin(local_invocation_id)
+  %group_id:ptr<__in, vec3<u32>, read> = var @builtin(workgroup_id)
+}
+
+%foo = func():void {
+  $B2: {
+    %5:vec3<u32> = load %gid
+    %6:vec3<u32> = load %lid
+    %7:vec3<u32> = add %5, %6
+    ret
+  }
+}
+%main1 = @compute func():void {
+  $B3: {
+    %9:void = call %foo
+    ret
+  }
+}
+%main2 = @compute func():void {
+  $B4: {
+    %11:vec3<u32> = load %group_id
+    %12:vec3<u32> = add %11, %11
+    %13:void = call %foo
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%foo = func(%gid:vec3<u32>, %lid:vec3<u32>):void {
+  $B1: {
+    %4:vec3<u32> = add %gid, %lid
+    ret
+  }
+}
+%main1 = @compute func(%gid_1:vec3<u32> [@global_invocation_id], %lid_1:vec3<u32> [@local_invocation_id]):void {  # %gid_1: 'gid', %lid_1: 'lid'
+  $B2: {
+    %8:void = call %foo, %gid_1, %lid_1
+    ret
+  }
+}
+%main2 = @compute func(%gid_2:vec3<u32> [@global_invocation_id], %lid_2:vec3<u32> [@local_invocation_id], %group_id:vec3<u32> [@workgroup_id]):void {  # %gid_2: 'gid', %lid_2: 'lid'
+  $B3: {
+    %13:vec3<u32> = add %group_id, %group_id
+    %14:void = call %foo, %gid_2, %lid_2
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Input_LoadVectorElement) {
+    auto* lid = b.Var("lid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kLocalInvocationId;
+        lid->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(lid);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kCompute);
+    b.Append(ep->Block(), [&] {
+        b.LoadVectorElement(lid, 2_u);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %lid:ptr<__in, vec3<u32>, read> = var @builtin(local_invocation_id)
+}
+
+%foo = @compute func():void {
+  $B2: {
+    %3:u32 = load_vector_element %lid, 2u
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%foo = @compute func(%lid:vec3<u32> [@local_invocation_id]):void {
+  $B1: {
+    %3:u32 = access %lid, 2u
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Input_AccessChains) {
+    auto* lid = b.Var("lid", ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kLocalInvocationId;
+        lid->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(lid);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kCompute);
+    b.Append(ep->Block(), [&] {
+        auto* access_1 = b.Access(ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()), lid);
+        auto* access_2 = b.Access(ty.ptr(core::AddressSpace::kIn, ty.vec3<u32>()), access_1);
+        auto* vec = b.Load(access_2);
+        auto* z = b.LoadVectorElement(access_2, 2_u);
+        b.Multiply<vec3<u32>>(vec, z);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %lid:ptr<__in, vec3<u32>, read> = var @builtin(local_invocation_id)
+}
+
+%foo = @compute func():void {
+  $B2: {
+    %3:ptr<__in, vec3<u32>, read> = access %lid
+    %4:ptr<__in, vec3<u32>, read> = access %3
+    %5:vec3<u32> = load %4
+    %6:u32 = load_vector_element %4, 2u
+    %7:vec3<u32> = mul %5, %6
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%foo = @compute func(%lid:vec3<u32> [@local_invocation_id]):void {
+  $B1: {
+    %3:u32 = access %lid, 2u
+    %4:vec3<u32> = mul %lid, %3
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_Struct_LocationOnEachMember) {
+    auto* colors_str = ty.Struct(
+        mod.symbols.New("Colors"),
+        Vector{
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color1"),
+                ty.vec4<f32>(),
+                LocationAttrs(1),
+            },
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color2"),
+                ty.vec4<f32>(),
+                LocationAttrs(2u, core::Interpolation{core::InterpolationType::kLinear,
+                                                      core::InterpolationSampling::kCentroid}),
+            },
+        });
+    auto* colors = b.Var("colors", ty.ptr(core::AddressSpace::kIn, colors_str));
+    mod.root_block->Append(colors);
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {
+        auto* ptr = ty.ptr(core::AddressSpace::kIn, ty.vec4<f32>());
+        auto* color1_value = b.Load(b.Access(ptr, colors, 0_u));
+        auto* color2_z_value = b.LoadVectorElement(b.Access(ptr, colors, 1_u), 2_u);
+        b.Multiply(ty.vec4<f32>(), color1_value, color2_z_value);
+        b.Return(foo);
+    });
+
+    auto* ep = b.Function("main", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        b.Call(foo);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(linear, centroid)
+}
+
+$B1: {  # root
+  %colors:ptr<__in, Colors, read> = var
+}
+
+%foo = func():void {
+  $B2: {
+    %3:ptr<__in, vec4<f32>, read> = access %colors, 0u
+    %4:vec4<f32> = load %3
+    %5:ptr<__in, vec4<f32>, read> = access %colors, 1u
+    %6:f32 = load_vector_element %5, 2u
+    %7:vec4<f32> = mul %4, %6
+    ret
+  }
+}
+%main = @fragment func():void {
+  $B3: {
+    %9:void = call %foo
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(linear, centroid)
+}
+
+%foo = func(%colors:Colors):void {
+  $B1: {
+    %3:vec4<f32> = access %colors, 0u
+    %4:vec4<f32> = access %colors, 1u
+    %5:f32 = access %4, 2u
+    %6:vec4<f32> = mul %3, %5
+    ret
+  }
+}
+%main = @fragment func(%colors_1:Colors):void {  # %colors_1: 'colors'
+  $B2: {
+    %9:void = call %foo, %colors_1
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_Struct_LocationOnVariable) {
+    auto* colors_str =
+        ty.Struct(mod.symbols.New("Colors"),
+                  Vector{
+                      core::type::Manager::StructMemberDesc{
+                          mod.symbols.New("color1"),
+                          ty.vec4<f32>(),
+                      },
+                      core::type::Manager::StructMemberDesc{
+                          mod.symbols.New("color2"),
+                          ty.vec4<f32>(),
+                          core::type::StructMemberAttributes{
+                              /* location */ std::nullopt,
+                              /* index */ std::nullopt,
+                              /* color */ std::nullopt,
+                              /* builtin */ std::nullopt,
+                              /* interpolation */
+                              core::Interpolation{core::InterpolationType::kPerspective,
+                                                  core::InterpolationSampling::kCentroid},
+                              /* invariant */ false,
+                          },
+                      },
+                  });
+    auto* colors = b.Var("colors", ty.ptr(core::AddressSpace::kIn, colors_str));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        colors->SetAttributes(attributes);
+    }
+    mod.root_block->Append(colors);
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {
+        auto* ptr = ty.ptr(core::AddressSpace::kIn, ty.vec4<f32>());
+        auto* color1_value = b.Load(b.Access(ptr, colors, 0_u));
+        auto* color2_z_value = b.LoadVectorElement(b.Access(ptr, colors, 1_u), 2_u);
+        b.Multiply(ty.vec4<f32>(), color1_value, color2_z_value);
+        b.Return(foo);
+    });
+
+    auto* ep = b.Function("main", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        b.Call(foo);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0)
+  color2:vec4<f32> @offset(16), @interpolate(perspective, centroid)
+}
+
+$B1: {  # root
+  %colors:ptr<__in, Colors, read> = var @location(1)
+}
+
+%foo = func():void {
+  $B2: {
+    %3:ptr<__in, vec4<f32>, read> = access %colors, 0u
+    %4:vec4<f32> = load %3
+    %5:ptr<__in, vec4<f32>, read> = access %colors, 1u
+    %6:f32 = load_vector_element %5, 2u
+    %7:vec4<f32> = mul %4, %6
+    ret
+  }
+}
+%main = @fragment func():void {
+  $B3: {
+    %9:void = call %foo
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(perspective, centroid)
+}
+
+%foo = func(%colors:Colors):void {
+  $B1: {
+    %3:vec4<f32> = access %colors, 0u
+    %4:vec4<f32> = access %colors, 1u
+    %5:f32 = access %4, 2u
+    %6:vec4<f32> = mul %3, %5
+    ret
+  }
+}
+%main = @fragment func(%colors_1:Colors):void {  # %colors_1: 'colors'
+  $B2: {
+    %9:void = call %foo, %colors_1
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_Struct_InterpolateOnVariable) {
+    auto* colors_str = ty.Struct(
+        mod.symbols.New("Colors"),
+        Vector{
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color1"),
+                ty.vec4<f32>(),
+                LocationAttrs(1),
+            },
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color2"),
+                ty.vec4<f32>(),
+                LocationAttrs(2u, core::Interpolation{core::InterpolationType::kLinear,
+                                                      core::InterpolationSampling::kSample}),
+            },
+        });
+    auto* colors = b.Var("colors", ty.ptr(core::AddressSpace::kIn, colors_str));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.interpolation = core::Interpolation{core::InterpolationType::kPerspective,
+                                                       core::InterpolationSampling::kCentroid};
+        colors->SetAttributes(attributes);
+    }
+    mod.root_block->Append(colors);
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {
+        auto* ptr = ty.ptr(core::AddressSpace::kIn, ty.vec4<f32>());
+        auto* color1_value = b.Load(b.Access(ptr, colors, 0_u));
+        auto* color2_z_value = b.LoadVectorElement(b.Access(ptr, colors, 1_u), 2_u);
+        b.Multiply(ty.vec4<f32>(), color1_value, color2_z_value);
+        b.Return(foo);
+    });
+
+    auto* ep = b.Function("main", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        b.Call(foo);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(linear, sample)
+}
+
+$B1: {  # root
+  %colors:ptr<__in, Colors, read> = var @interpolate(perspective, centroid)
+}
+
+%foo = func():void {
+  $B2: {
+    %3:ptr<__in, vec4<f32>, read> = access %colors, 0u
+    %4:vec4<f32> = load %3
+    %5:ptr<__in, vec4<f32>, read> = access %colors, 1u
+    %6:f32 = load_vector_element %5, 2u
+    %7:vec4<f32> = mul %4, %6
+    ret
+  }
+}
+%main = @fragment func():void {
+  $B3: {
+    %9:void = call %foo
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1), @interpolate(perspective, centroid)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(linear, sample)
+}
+
+%foo = func(%colors:Colors):void {
+  $B1: {
+    %3:vec4<f32> = access %colors, 0u
+    %4:vec4<f32> = access %colors, 1u
+    %5:f32 = access %4, 2u
+    %6:vec4<f32> = mul %3, %5
+    ret
+  }
+}
+%main = @fragment func(%colors_1:Colors):void {  # %colors_1: 'colors'
+  $B2: {
+    %9:void = call %foo, %colors_1
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_Struct_LoadWholeStruct) {
+    auto* colors_str = ty.Struct(
+        mod.symbols.New("Colors"),
+        Vector{
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color1"),
+                ty.vec4<f32>(),
+                LocationAttrs(1),
+            },
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color2"),
+                ty.vec4<f32>(),
+                LocationAttrs(2u, core::Interpolation{core::InterpolationType::kLinear,
+                                                      core::InterpolationSampling::kCentroid}),
+            },
+        });
+    auto* colors = b.Var("colors", ty.ptr(core::AddressSpace::kIn, colors_str));
+    mod.root_block->Append(colors);
+
+    auto* foo = b.Function("foo", ty.void_());
+    b.Append(foo->Block(), [&] {
+        auto* load = b.Load(colors);
+        auto* color1_value = b.Access<vec4<f32>>(load, 0_u);
+        auto* color2_z_value = b.Access<f32>(load, 1_u, 2_u);
+        b.Multiply(ty.vec4<f32>(), color1_value, color2_z_value);
+        b.Return(foo);
+    });
+
+    auto* ep = b.Function("main", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        b.Call(foo);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(linear, centroid)
+}
+
+$B1: {  # root
+  %colors:ptr<__in, Colors, read> = var
+}
+
+%foo = func():void {
+  $B2: {
+    %3:Colors = load %colors
+    %4:vec4<f32> = access %3, 0u
+    %5:f32 = access %3, 1u, 2u
+    %6:vec4<f32> = mul %4, %5
+    ret
+  }
+}
+%main = @fragment func():void {
+  $B3: {
+    %8:void = call %foo
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(linear, centroid)
+}
+
+%foo = func(%colors:Colors):void {
+  $B1: {
+    %3:vec4<f32> = access %colors, 0u
+    %4:f32 = access %colors, 1u, 2u
+    %5:vec4<f32> = mul %3, %4
+    ret
+  }
+}
+%main = @fragment func(%colors_1:Colors):void {  # %colors_1: 'colors'
+  $B2: {
+    %8:void = call %foo, %colors_1
+    ret
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, SingleOutput_Builtin) {
+    auto* position = b.Var("position", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kPosition;
+        position->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(position);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep->Block(), [&] {  //
+        b.Store(position, b.Splat<vec4<f32>>(1_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %position:ptr<__out, vec4<f32>, read_write> = var @builtin(position)
+}
+
+%foo = @vertex func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %position:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    ret
+  }
+}
+%foo = @vertex func():vec4<f32> [@position] {
+  $B3: {
+    %4:void = call %foo_inner
+    %5:vec4<f32> = load %position
+    ret %5
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, SingleOutput_Builtin_WithInvariant) {
+    auto* position = b.Var("position", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kPosition;
+        attributes.invariant = true;
+        position->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(position);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep->Block(), [&] {  //
+        b.Store(position, b.Splat<vec4<f32>>(1_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %position:ptr<__out, vec4<f32>, read_write> = var @invariant @builtin(position)
+}
+
+%foo = @vertex func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %position:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    ret
+  }
+}
+%foo = @vertex func():vec4<f32> [@invariant, @position] {
+  $B3: {
+    %4:void = call %foo_inner
+    %5:vec4<f32> = load %position
+    ret %5
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, SingleOutput_Location) {
+    auto* color = b.Var("color", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        color->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(color);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {  //
+        b.Store(color, b.Splat<vec4<f32>>(1_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %color:ptr<__out, vec4<f32>, read_write> = var @location(1)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %color:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    ret
+  }
+}
+%foo = @fragment func():vec4<f32> [@location(1)] {
+  $B3: {
+    %4:void = call %foo_inner
+    %5:vec4<f32> = load %color
+    ret %5
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, SingleOutput_Location_WithInterpolation) {
+    auto* color = b.Var("color", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        attributes.interpolation = core::Interpolation{core::InterpolationType::kPerspective,
+                                                       core::InterpolationSampling::kCentroid};
+        color->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(color);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {  //
+        b.Store(color, b.Splat<vec4<f32>>(1_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %color:ptr<__out, vec4<f32>, read_write> = var @location(1) @interpolate(perspective, centroid)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %color:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    ret
+  }
+}
+%foo = @fragment func():vec4<f32> [@location(1), @interpolate(perspective, centroid)] {
+  $B3: {
+    %4:void = call %foo_inner
+    %5:vec4<f32> = load %color
+    ret %5
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, MultipleOutputs) {
+    auto* position = b.Var("position", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kPosition;
+        attributes.invariant = true;
+        position->SetAttributes(std::move(attributes));
+    }
+    auto* color1 = b.Var("color1", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        color1->SetAttributes(std::move(attributes));
+    }
+    auto* color2 = b.Var("color2", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        attributes.interpolation = core::Interpolation{core::InterpolationType::kPerspective,
+                                                       core::InterpolationSampling::kCentroid};
+        color2->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(position);
+    mod.root_block->Append(color1);
+    mod.root_block->Append(color2);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep->Block(), [&] {  //
+        b.Store(position, b.Splat<vec4<f32>>(1_f, 4));
+        b.Store(color1, b.Splat<vec4<f32>>(0.5_f, 4));
+        b.Store(color2, b.Splat<vec4<f32>>(0.25_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %position:ptr<__out, vec4<f32>, read_write> = var @invariant @builtin(position)
+  %color1:ptr<__out, vec4<f32>, read_write> = var @location(1)
+  %color2:ptr<__out, vec4<f32>, read_write> = var @location(1) @interpolate(perspective, centroid)
+}
+
+%foo = @vertex func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    store %color1, vec4<f32>(0.5f)
+    store %color2, vec4<f32>(0.25f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+tint_symbol = struct @align(16) {
+  position:vec4<f32> @offset(0), @invariant, @builtin(position)
+  color1:vec4<f32> @offset(16), @location(1)
+  color2:vec4<f32> @offset(32), @location(1), @interpolate(perspective, centroid)
+}
+
+$B1: {  # root
+  %position:ptr<private, vec4<f32>, read_write> = var
+  %color1:ptr<private, vec4<f32>, read_write> = var
+  %color2:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    store %color1, vec4<f32>(0.5f)
+    store %color2, vec4<f32>(0.25f)
+    ret
+  }
+}
+%foo = @vertex func():tint_symbol {
+  $B3: {
+    %6:void = call %foo_inner
+    %7:vec4<f32> = load %position
+    %8:vec4<f32> = load %color1
+    %9:vec4<f32> = load %color2
+    %10:tint_symbol = construct %7, %8, %9
+    ret %10
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Outputs_Struct_LocationOnEachMember) {
+    auto* builtin_str =
+        ty.Struct(mod.symbols.New("Builtins"), Vector{
+                                                   core::type::Manager::StructMemberDesc{
+                                                       mod.symbols.New("position"),
+                                                       ty.vec4<f32>(),
+                                                       BuiltinAttrs(core::BuiltinValue::kPosition),
+                                                   },
+                                               });
+    auto* colors_str = ty.Struct(
+        mod.symbols.New("Colors"),
+        Vector{
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color1"),
+                ty.vec4<f32>(),
+                LocationAttrs(1),
+            },
+            core::type::Manager::StructMemberDesc{
+                mod.symbols.New("color2"),
+                ty.vec4<f32>(),
+                LocationAttrs(2u, core::Interpolation{core::InterpolationType::kPerspective,
+                                                      core::InterpolationSampling::kCentroid}),
+            },
+        });
+
+    auto* builtins = b.Var("builtins", ty.ptr(core::AddressSpace::kOut, builtin_str));
+    auto* colors = b.Var("colors", ty.ptr(core::AddressSpace::kOut, colors_str));
+    mod.root_block->Append(builtins);
+    mod.root_block->Append(colors);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep->Block(), [&] {  //
+        auto* ptr = ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>());
+        b.Store(b.Access(ptr, builtins, 0_u), b.Splat<vec4<f32>>(1_f, 4));
+        b.Store(b.Access(ptr, colors, 0_u), b.Splat<vec4<f32>>(0.5_f, 4));
+        b.Store(b.Access(ptr, colors, 1_u), b.Splat<vec4<f32>>(0.25_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+Builtins = struct @align(16) {
+  position:vec4<f32> @offset(0), @builtin(position)
+}
+
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(1)
+  color2:vec4<f32> @offset(16), @location(2), @interpolate(perspective, centroid)
+}
+
+$B1: {  # root
+  %builtins:ptr<__out, Builtins, read_write> = var
+  %colors:ptr<__out, Colors, read_write> = var
+}
+
+%foo = @vertex func():void {
+  $B2: {
+    %4:ptr<__out, vec4<f32>, read_write> = access %builtins, 0u
+    store %4, vec4<f32>(1.0f)
+    %5:ptr<__out, vec4<f32>, read_write> = access %colors, 0u
+    store %5, vec4<f32>(0.5f)
+    %6:ptr<__out, vec4<f32>, read_write> = access %colors, 1u
+    store %6, vec4<f32>(0.25f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Builtins = struct @align(16) {
+  position:vec4<f32> @offset(0)
+}
+
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0)
+  color2:vec4<f32> @offset(16)
+}
+
+tint_symbol = struct @align(16) {
+  position:vec4<f32> @offset(0), @builtin(position)
+  color1:vec4<f32> @offset(16), @location(1)
+  color2:vec4<f32> @offset(32), @location(2), @interpolate(perspective, centroid)
+}
+
+$B1: {  # root
+  %builtins:ptr<private, Builtins, read_write> = var
+  %colors:ptr<private, Colors, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    %4:ptr<private, vec4<f32>, read_write> = access %builtins, 0u
+    store %4, vec4<f32>(1.0f)
+    %5:ptr<private, vec4<f32>, read_write> = access %colors, 0u
+    store %5, vec4<f32>(0.5f)
+    %6:ptr<private, vec4<f32>, read_write> = access %colors, 1u
+    store %6, vec4<f32>(0.25f)
+    ret
+  }
+}
+%foo = @vertex func():tint_symbol {
+  $B3: {
+    %8:void = call %foo_inner
+    %9:ptr<private, vec4<f32>, read_write> = access %builtins, 0u
+    %10:vec4<f32> = load %9
+    %11:ptr<private, vec4<f32>, read_write> = access %colors, 0u
+    %12:vec4<f32> = load %11
+    %13:ptr<private, vec4<f32>, read_write> = access %colors, 1u
+    %14:vec4<f32> = load %13
+    %15:tint_symbol = construct %10, %12, %14
+    ret %15
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Outputs_Struct_LocationOnVariable) {
+    auto* builtin_str =
+        ty.Struct(mod.symbols.New("Builtins"), Vector{
+                                                   core::type::Manager::StructMemberDesc{
+                                                       mod.symbols.New("position"),
+                                                       ty.vec4<f32>(),
+                                                       BuiltinAttrs(core::BuiltinValue::kPosition),
+                                                   },
+                                               });
+    auto* colors_str =
+        ty.Struct(mod.symbols.New("Colors"),
+                  Vector{
+                      core::type::Manager::StructMemberDesc{
+                          mod.symbols.New("color1"),
+                          ty.vec4<f32>(),
+                      },
+                      core::type::Manager::StructMemberDesc{
+                          mod.symbols.New("color2"),
+                          ty.vec4<f32>(),
+                          core::type::StructMemberAttributes{
+                              /* location */ std::nullopt,
+                              /* index */ std::nullopt,
+                              /* color */ std::nullopt,
+                              /* builtin */ std::nullopt,
+                              /* interpolation */
+                              core::Interpolation{core::InterpolationType::kPerspective,
+                                                  core::InterpolationSampling::kCentroid},
+                              /* invariant */ false,
+                          },
+                      },
+                  });
+
+    auto* builtins = b.Var("builtins", ty.ptr(core::AddressSpace::kOut, builtin_str));
+    auto* colors = b.Var("colors", ty.ptr(core::AddressSpace::kOut, colors_str));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        colors->SetAttributes(attributes);
+    }
+    mod.root_block->Append(builtins);
+    mod.root_block->Append(colors);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep->Block(), [&] {  //
+        auto* ptr = ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>());
+        b.Store(b.Access(ptr, builtins, 0_u), b.Splat<vec4<f32>>(1_f, 4));
+        b.Store(b.Access(ptr, colors, 0_u), b.Splat<vec4<f32>>(0.5_f, 4));
+        b.Store(b.Access(ptr, colors, 1_u), b.Splat<vec4<f32>>(0.25_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+Builtins = struct @align(16) {
+  position:vec4<f32> @offset(0), @builtin(position)
+}
+
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0)
+  color2:vec4<f32> @offset(16), @interpolate(perspective, centroid)
+}
+
+$B1: {  # root
+  %builtins:ptr<__out, Builtins, read_write> = var
+  %colors:ptr<__out, Colors, read_write> = var @location(1)
+}
+
+%foo = @vertex func():void {
+  $B2: {
+    %4:ptr<__out, vec4<f32>, read_write> = access %builtins, 0u
+    store %4, vec4<f32>(1.0f)
+    %5:ptr<__out, vec4<f32>, read_write> = access %colors, 0u
+    store %5, vec4<f32>(0.5f)
+    %6:ptr<__out, vec4<f32>, read_write> = access %colors, 1u
+    store %6, vec4<f32>(0.25f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Builtins = struct @align(16) {
+  position:vec4<f32> @offset(0)
+}
+
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0)
+  color2:vec4<f32> @offset(16)
+}
+
+tint_symbol = struct @align(16) {
+  position:vec4<f32> @offset(0), @builtin(position)
+  color1:vec4<f32> @offset(16), @location(1)
+  color2:vec4<f32> @offset(32), @location(2), @interpolate(perspective, centroid)
+}
+
+$B1: {  # root
+  %builtins:ptr<private, Builtins, read_write> = var
+  %colors:ptr<private, Colors, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    %4:ptr<private, vec4<f32>, read_write> = access %builtins, 0u
+    store %4, vec4<f32>(1.0f)
+    %5:ptr<private, vec4<f32>, read_write> = access %colors, 0u
+    store %5, vec4<f32>(0.5f)
+    %6:ptr<private, vec4<f32>, read_write> = access %colors, 1u
+    store %6, vec4<f32>(0.25f)
+    ret
+  }
+}
+%foo = @vertex func():tint_symbol {
+  $B3: {
+    %8:void = call %foo_inner
+    %9:ptr<private, vec4<f32>, read_write> = access %builtins, 0u
+    %10:vec4<f32> = load %9
+    %11:ptr<private, vec4<f32>, read_write> = access %colors, 0u
+    %12:vec4<f32> = load %11
+    %13:ptr<private, vec4<f32>, read_write> = access %colors, 1u
+    %14:vec4<f32> = load %13
+    %15:tint_symbol = construct %10, %12, %14
+    ret %15
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Outputs_Struct_InterpolateOnVariable) {
+    auto* builtin_str =
+        ty.Struct(mod.symbols.New("Builtins"), Vector{
+                                                   core::type::Manager::StructMemberDesc{
+                                                       mod.symbols.New("position"),
+                                                       ty.vec4<f32>(),
+                                                       BuiltinAttrs(core::BuiltinValue::kPosition),
+                                                   },
+                                               });
+    auto* colors_str =
+        ty.Struct(mod.symbols.New("Colors"),
+                  Vector{
+                      core::type::Manager::StructMemberDesc{
+                          mod.symbols.New("color1"),
+                          ty.vec4<f32>(),
+                          LocationAttrs(2),
+                      },
+                      core::type::Manager::StructMemberDesc{
+                          mod.symbols.New("color2"),
+                          ty.vec4<f32>(),
+                          LocationAttrs(3, core::Interpolation{core::InterpolationType::kFlat}),
+                      },
+                  });
+
+    auto* builtins = b.Var("builtins", ty.ptr(core::AddressSpace::kOut, builtin_str));
+    auto* colors = b.Var("colors", ty.ptr(core::AddressSpace::kOut, colors_str));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.interpolation = core::Interpolation{core::InterpolationType::kPerspective,
+                                                       core::InterpolationSampling::kCentroid};
+        colors->SetAttributes(attributes);
+    }
+    mod.root_block->Append(builtins);
+    mod.root_block->Append(colors);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep->Block(), [&] {  //
+        auto* ptr = ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>());
+        b.Store(b.Access(ptr, builtins, 0_u), b.Splat<vec4<f32>>(1_f, 4));
+        b.Store(b.Access(ptr, colors, 0_u), b.Splat<vec4<f32>>(0.5_f, 4));
+        b.Store(b.Access(ptr, colors, 1_u), b.Splat<vec4<f32>>(0.25_f, 4));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+Builtins = struct @align(16) {
+  position:vec4<f32> @offset(0), @builtin(position)
+}
+
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0), @location(2)
+  color2:vec4<f32> @offset(16), @location(3), @interpolate(flat)
+}
+
+$B1: {  # root
+  %builtins:ptr<__out, Builtins, read_write> = var
+  %colors:ptr<__out, Colors, read_write> = var @interpolate(perspective, centroid)
+}
+
+%foo = @vertex func():void {
+  $B2: {
+    %4:ptr<__out, vec4<f32>, read_write> = access %builtins, 0u
+    store %4, vec4<f32>(1.0f)
+    %5:ptr<__out, vec4<f32>, read_write> = access %colors, 0u
+    store %5, vec4<f32>(0.5f)
+    %6:ptr<__out, vec4<f32>, read_write> = access %colors, 1u
+    store %6, vec4<f32>(0.25f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Builtins = struct @align(16) {
+  position:vec4<f32> @offset(0)
+}
+
+Colors = struct @align(16) {
+  color1:vec4<f32> @offset(0)
+  color2:vec4<f32> @offset(16)
+}
+
+tint_symbol = struct @align(16) {
+  position:vec4<f32> @offset(0), @builtin(position)
+  color1:vec4<f32> @offset(16), @location(2), @interpolate(perspective, centroid)
+  color2:vec4<f32> @offset(32), @location(3), @interpolate(flat)
+}
+
+$B1: {  # root
+  %builtins:ptr<private, Builtins, read_write> = var
+  %colors:ptr<private, Colors, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    %4:ptr<private, vec4<f32>, read_write> = access %builtins, 0u
+    store %4, vec4<f32>(1.0f)
+    %5:ptr<private, vec4<f32>, read_write> = access %colors, 0u
+    store %5, vec4<f32>(0.5f)
+    %6:ptr<private, vec4<f32>, read_write> = access %colors, 1u
+    store %6, vec4<f32>(0.25f)
+    ret
+  }
+}
+%foo = @vertex func():tint_symbol {
+  $B3: {
+    %8:void = call %foo_inner
+    %9:ptr<private, vec4<f32>, read_write> = access %builtins, 0u
+    %10:vec4<f32> = load %9
+    %11:ptr<private, vec4<f32>, read_write> = access %colors, 0u
+    %12:vec4<f32> = load %11
+    %13:ptr<private, vec4<f32>, read_write> = access %colors, 1u
+    %14:vec4<f32> = load %13
+    %15:tint_symbol = construct %10, %12, %14
+    ret %15
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Outputs_UsedByMultipleEntryPoints) {
+    auto* position = b.Var("position", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kPosition;
+        attributes.invariant = true;
+        position->SetAttributes(std::move(attributes));
+    }
+    auto* color1 = b.Var("color1", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        color1->SetAttributes(std::move(attributes));
+    }
+    auto* color2 = b.Var("color2", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        attributes.interpolation = core::Interpolation{core::InterpolationType::kPerspective,
+                                                       core::InterpolationSampling::kCentroid};
+        color2->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(position);
+    mod.root_block->Append(color1);
+    mod.root_block->Append(color2);
+
+    auto* ep1 = b.Function("main1", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep1->Block(), [&] {  //
+        b.Store(position, b.Splat<vec4<f32>>(1_f, 4));
+        b.Return(ep1);
+    });
+
+    auto* ep2 = b.Function("main2", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep2->Block(), [&] {  //
+        b.Store(position, b.Splat<vec4<f32>>(1_f, 4));
+        b.Store(color1, b.Splat<vec4<f32>>(0.5_f, 4));
+        b.Return(ep2);
+    });
+
+    auto* ep3 = b.Function("main3", ty.void_(), core::ir::Function::PipelineStage::kVertex);
+    b.Append(ep3->Block(), [&] {  //
+        b.Store(position, b.Splat<vec4<f32>>(1_f, 4));
+        b.Store(color2, b.Splat<vec4<f32>>(0.25_f, 4));
+        b.Return(ep3);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %position:ptr<__out, vec4<f32>, read_write> = var @invariant @builtin(position)
+  %color1:ptr<__out, vec4<f32>, read_write> = var @location(1)
+  %color2:ptr<__out, vec4<f32>, read_write> = var @location(1) @interpolate(perspective, centroid)
+}
+
+%main1 = @vertex func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    ret
+  }
+}
+%main2 = @vertex func():void {
+  $B3: {
+    store %position, vec4<f32>(1.0f)
+    store %color1, vec4<f32>(0.5f)
+    ret
+  }
+}
+%main3 = @vertex func():void {
+  $B4: {
+    store %position, vec4<f32>(1.0f)
+    store %color2, vec4<f32>(0.25f)
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+tint_symbol = struct @align(16) {
+  position:vec4<f32> @offset(0), @invariant, @builtin(position)
+  color1:vec4<f32> @offset(16), @location(1)
+}
+
+tint_symbol_1 = struct @align(16) {
+  position:vec4<f32> @offset(0), @invariant, @builtin(position)
+  color2:vec4<f32> @offset(16), @location(1), @interpolate(perspective, centroid)
+}
+
+$B1: {  # root
+  %position:ptr<private, vec4<f32>, read_write> = var
+  %color1:ptr<private, vec4<f32>, read_write> = var
+  %color2:ptr<private, vec4<f32>, read_write> = var
+}
+
+%main1_inner = func():void {
+  $B2: {
+    store %position, vec4<f32>(1.0f)
+    ret
+  }
+}
+%main2_inner = func():void {
+  $B3: {
+    store %position, vec4<f32>(1.0f)
+    store %color1, vec4<f32>(0.5f)
+    ret
+  }
+}
+%main3_inner = func():void {
+  $B4: {
+    store %position, vec4<f32>(1.0f)
+    store %color2, vec4<f32>(0.25f)
+    ret
+  }
+}
+%main1 = @vertex func():vec4<f32> [@invariant, @position] {
+  $B5: {
+    %8:void = call %main1_inner
+    %9:vec4<f32> = load %position
+    ret %9
+  }
+}
+%main2 = @vertex func():tint_symbol {
+  $B6: {
+    %11:void = call %main2_inner
+    %12:vec4<f32> = load %position
+    %13:vec4<f32> = load %color1
+    %14:tint_symbol = construct %12, %13
+    ret %14
+  }
+}
+%main3 = @vertex func():tint_symbol_1 {
+  $B7: {
+    %16:void = call %main3_inner
+    %17:vec4<f32> = load %position
+    %18:vec4<f32> = load %color2
+    %19:tint_symbol_1 = construct %17, %18
+    ret %19
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Output_LoadAndStore) {
+    auto* color = b.Var("color", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        color->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(color);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {  //
+        b.Store(color, b.Splat<vec4<f32>>(1_f, 4));
+        auto* load = b.Load(color);
+        auto* mul = b.Multiply<vec4<f32>>(load, 2_f);
+        b.Store(color, mul);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %color:ptr<__out, vec4<f32>, read_write> = var @location(1)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    %3:vec4<f32> = load %color
+    %4:vec4<f32> = mul %3, 2.0f
+    store %color, %4
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %color:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    %3:vec4<f32> = load %color
+    %4:vec4<f32> = mul %3, 2.0f
+    store %color, %4
+    ret
+  }
+}
+%foo = @fragment func():vec4<f32> [@location(1)] {
+  $B3: {
+    %6:void = call %foo_inner
+    %7:vec4<f32> = load %color
+    ret %7
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Output_LoadVectorElementAndStoreVectorElement) {
+    auto* color = b.Var("color", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        color->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(color);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {  //
+        b.Store(color, b.Splat<vec4<f32>>(1_f, 4));
+        auto* load = b.LoadVectorElement(color, 2_u);
+        auto* mul = b.Multiply<f32>(load, 2_f);
+        b.StoreVectorElement(color, 2_u, mul);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %color:ptr<__out, vec4<f32>, read_write> = var @location(1)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    %3:f32 = load_vector_element %color, 2u
+    %4:f32 = mul %3, 2.0f
+    store_vector_element %color, 2u, %4
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %color:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    store %color, vec4<f32>(1.0f)
+    %3:f32 = load_vector_element %color, 2u
+    %4:f32 = mul %3, 2.0f
+    store_vector_element %color, 2u, %4
+    ret
+  }
+}
+%foo = @fragment func():vec4<f32> [@location(1)] {
+  $B3: {
+    %6:void = call %foo_inner
+    %7:vec4<f32> = load %color
+    ret %7
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Output_AccessChain) {
+    auto* color = b.Var("color", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1u;
+        color->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(color);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {  //
+        auto* access_1 = b.Access(ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()), color);
+        auto* access_2 = b.Access(ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()), access_1);
+        auto* load = b.LoadVectorElement(access_2, 2_u);
+        auto* mul = b.Multiply<vec4<f32>>(b.Splat<vec4<f32>>(1_f, 4), load);
+        b.Store(access_2, mul);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %color:ptr<__out, vec4<f32>, read_write> = var @location(1)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    %3:ptr<__out, vec4<f32>, read_write> = access %color
+    %4:ptr<__out, vec4<f32>, read_write> = access %3
+    %5:f32 = load_vector_element %4, 2u
+    %6:vec4<f32> = mul vec4<f32>(1.0f), %5
+    store %4, %6
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %color:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func():void {
+  $B2: {
+    %3:ptr<private, vec4<f32>, read_write> = access %color
+    %4:ptr<private, vec4<f32>, read_write> = access %3
+    %5:f32 = load_vector_element %4, 2u
+    %6:vec4<f32> = mul vec4<f32>(1.0f), %5
+    store %4, %6
+    ret
+  }
+}
+%foo = @fragment func():vec4<f32> [@location(1)] {
+  $B3: {
+    %8:void = call %foo_inner
+    %9:vec4<f32> = load %color
+    ret %9
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(SpirvReader_ShaderIOTest, Inputs_And_Outputs) {
+    auto* position = b.Var("position", ty.ptr(core::AddressSpace::kIn, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kPosition;
+        attributes.invariant = true;
+        position->SetAttributes(std::move(attributes));
+    }
+    auto* color_in = b.Var("color_in", ty.ptr(core::AddressSpace::kIn, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 0;
+        color_in->SetAttributes(std::move(attributes));
+    }
+    auto* color_out_1 = b.Var("color_out_1", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 1;
+        color_out_1->SetAttributes(std::move(attributes));
+    }
+    auto* color_out_2 = b.Var("color_out_2", ty.ptr(core::AddressSpace::kOut, ty.vec4<f32>()));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.location = 2;
+        color_out_2->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(position);
+    mod.root_block->Append(color_in);
+    mod.root_block->Append(color_out_1);
+    mod.root_block->Append(color_out_2);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        auto* position_value = b.Load(position);
+        auto* color_in_value = b.Load(color_in);
+        b.Store(color_out_1, position_value);
+        b.Store(color_out_2, color_in_value);
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %position:ptr<__in, vec4<f32>, read> = var @invariant @builtin(position)
+  %color_in:ptr<__in, vec4<f32>, read> = var @location(0)
+  %color_out_1:ptr<__out, vec4<f32>, read_write> = var @location(1)
+  %color_out_2:ptr<__out, vec4<f32>, read_write> = var @location(2)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    %6:vec4<f32> = load %position
+    %7:vec4<f32> = load %color_in
+    store %color_out_1, %6
+    store %color_out_2, %7
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+tint_symbol = struct @align(16) {
+  color_out_1:vec4<f32> @offset(0), @location(1)
+  color_out_2:vec4<f32> @offset(16), @location(2)
+}
+
+$B1: {  # root
+  %color_out_1:ptr<private, vec4<f32>, read_write> = var
+  %color_out_2:ptr<private, vec4<f32>, read_write> = var
+}
+
+%foo_inner = func(%position:vec4<f32>, %color_in:vec4<f32>):void {
+  $B2: {
+    store %color_out_1, %position
+    store %color_out_2, %color_in
+    ret
+  }
+}
+%foo = @fragment func(%position_1:vec4<f32> [@invariant, @position], %color_in_1:vec4<f32> [@location(0)]):tint_symbol {  # %position_1: 'position', %color_in_1: 'color_in'
+  $B3: {
+    %9:void = call %foo_inner, %position_1, %color_in_1
+    %10:vec4<f32> = load %color_out_1
+    %11:vec4<f32> = load %color_out_2
+    %12:tint_symbol = construct %10, %11
+    ret %12
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+// Test that a sample mask array is converted to a scalar u32 for the entry point.
+TEST_F(SpirvReader_ShaderIOTest, SampleMask) {
+    auto* arr = ty.array<u32, 1>();
+    auto* mask_in = b.Var("mask_in", ty.ptr(core::AddressSpace::kIn, arr));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kSampleMask;
+        mask_in->SetAttributes(std::move(attributes));
+    }
+    auto* mask_out = b.Var("mask_out", ty.ptr(core::AddressSpace::kOut, arr));
+    {
+        core::ir::IOAttributes attributes;
+        attributes.builtin = core::BuiltinValue::kSampleMask;
+        mask_out->SetAttributes(std::move(attributes));
+    }
+    mod.root_block->Append(mask_in);
+    mod.root_block->Append(mask_out);
+
+    auto* ep = b.Function("foo", ty.void_(), core::ir::Function::PipelineStage::kFragment);
+    b.Append(ep->Block(), [&] {
+        auto* mask_value = b.Load(mask_in);
+        auto* doubled = b.Multiply(ty.u32(), b.Access(ty.u32(), mask_value, 0_u), 2_u);
+        b.Store(mask_out, b.Construct(arr, doubled));
+        b.Return(ep);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %mask_in:ptr<__in, array<u32, 1>, read> = var @builtin(sample_mask)
+  %mask_out:ptr<__out, array<u32, 1>, read_write> = var @builtin(sample_mask)
+}
+
+%foo = @fragment func():void {
+  $B2: {
+    %4:array<u32, 1> = load %mask_in
+    %5:u32 = access %4, 0u
+    %6:u32 = mul %5, 2u
+    %7:array<u32, 1> = construct %6
+    store %mask_out, %7
+    ret
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %mask_out:ptr<private, array<u32, 1>, read_write> = var
+}
+
+%foo_inner = func(%mask_in:array<u32, 1>):void {
+  $B2: {
+    %4:u32 = access %mask_in, 0u
+    %5:u32 = mul %4, 2u
+    %6:array<u32, 1> = construct %5
+    store %mask_out, %6
+    ret
+  }
+}
+%foo = @fragment func(%mask_in_1:u32 [@sample_mask]):u32 [@sample_mask] {  # %mask_in_1: 'mask_in'
+  $B3: {
+    %9:array<u32, 1> = construct %mask_in_1
+    %10:void = call %foo_inner, %9
+    %11:array<u32, 1> = load %mask_out
+    %12:u32 = access %11, 0u
+    ret %12
+  }
+}
+)";
+
+    Run(ShaderIO);
+
+    EXPECT_EQ(expect, str());
+}
+
+}  // namespace
+}  // namespace tint::spirv::reader::lower
diff --git a/src/tint/lang/spirv/reader/lower/vector_element_pointer.cc b/src/tint/lang/spirv/reader/lower/vector_element_pointer.cc
index daf59df..948104d 100644
--- a/src/tint/lang/spirv/reader/lower/vector_element_pointer.cc
+++ b/src/tint/lang/spirv/reader/lower/vector_element_pointer.cc
@@ -131,9 +131,8 @@
             Switch(
                 use.instruction,
                 [&](core::ir::Load* load) {
-                    auto* lve = b.LoadVectorElement(object, index);
+                    auto* lve = b.LoadVectorElementWithResult(load->DetachResult(), object, index);
                     lve->InsertBefore(load);
-                    load->Result(0)->ReplaceAllUsesWith(lve->Result(0));
                     to_destroy.Push(load);
                 },
                 [&](core::ir::Store* store) {
diff --git a/src/tint/lang/spirv/reader/parser/struct_test.cc b/src/tint/lang/spirv/reader/parser/struct_test.cc
index 7c85914..f59236b 100644
--- a/src/tint/lang/spirv/reader/parser/struct_test.cc
+++ b/src/tint/lang/spirv/reader/parser/struct_test.cc
@@ -30,7 +30,7 @@
 namespace tint::spirv::reader {
 
 TEST_F(SpirvParserTest, Struct_Empty) {
-    EXPECT_DEATH(  //
+    EXPECT_DEATH_IF_SUPPORTED(  //
         {
             auto assembly = Assemble(R"(
                OpCapability Shader
diff --git a/src/tint/lang/spirv/reader/reader_test.cc b/src/tint/lang/spirv/reader/reader_test.cc
index d8ab351..5242819 100644
--- a/src/tint/lang/spirv/reader/reader_test.cc
+++ b/src/tint/lang/spirv/reader/reader_test.cc
@@ -154,5 +154,201 @@
 )");
 }
 
+TEST_F(SpirvReaderTest, ShaderInputs) {
+    auto got = Run(R"(
+               OpCapability Shader
+               OpCapability SampleRateShading
+               OpMemoryModel Logical GLSL450
+               OpEntryPoint Fragment %main "main" %coord %colors
+               OpExecutionMode %main OriginUpperLeft
+               OpDecorate %coord BuiltIn FragCoord
+               OpDecorate %colors Location 1
+               OpMemberDecorate %str 1 NoPerspective
+       %void = OpTypeVoid
+        %f32 = OpTypeFloat 32
+      %vec4f = OpTypeVector %f32 4
+    %fn_type = OpTypeFunction %void
+        %str = OpTypeStruct %vec4f %vec4f
+        %u32 = OpTypeInt 32 0
+      %u32_0 = OpConstant %u32 0
+      %u32_1 = OpConstant %u32 1
+
+%_ptr_Input_vec4f = OpTypePointer Input %vec4f
+  %_ptr_Input_str = OpTypePointer Input %str
+      %coord = OpVariable %_ptr_Input_vec4f Input
+     %colors = OpVariable %_ptr_Input_str Input
+
+       %main = OpFunction %void None %fn_type
+ %main_start = OpLabel
+   %access_a = OpAccessChain %_ptr_Input_vec4f %colors %u32_0
+   %access_b = OpAccessChain %_ptr_Input_vec4f %colors %u32_1
+          %a = OpLoad %vec4f %access_a
+          %b = OpLoad %vec4f %access_b
+          %c = OpLoad %vec4f %coord
+        %mul = OpFMul %vec4f %a %b
+        %add = OpFAdd %vec4f %mul %c
+               OpReturn
+               OpFunctionEnd
+)");
+    ASSERT_EQ(got, Success);
+    EXPECT_EQ(got, R"(
+tint_symbol_2 = struct @align(16) {
+  tint_symbol:vec4<f32> @offset(0), @location(1)
+  tint_symbol_1:vec4<f32> @offset(16), @location(2), @interpolate(linear, center)
+}
+
+%main = @fragment func(%2:vec4<f32> [@position], %3:tint_symbol_2):void {
+  $B1: {
+    %4:vec4<f32> = access %3, 0u
+    %5:vec4<f32> = access %3, 1u
+    %6:vec4<f32> = mul %4, %5
+    %7:vec4<f32> = add %6, %2
+    ret
+  }
+}
+)");
+}
+
+TEST_F(SpirvReaderTest, ShaderOutputs) {
+    auto got = Run(R"(
+               OpCapability Shader
+               OpMemoryModel Logical GLSL450
+               OpEntryPoint Fragment %main "main" %depth %colors
+               OpExecutionMode %main OriginUpperLeft
+               OpExecutionMode %main DepthReplacing
+               OpDecorate %depth BuiltIn FragDepth
+               OpDecorate %colors Location 1
+               OpMemberDecorate %str 1 NoPerspective
+       %void = OpTypeVoid
+        %f32 = OpTypeFloat 32
+      %vec4f = OpTypeVector %f32 4
+    %fn_type = OpTypeFunction %void
+        %str = OpTypeStruct %vec4f %vec4f
+        %u32 = OpTypeInt 32 0
+      %u32_0 = OpConstant %u32 0
+      %u32_1 = OpConstant %u32 1
+     %f32_42 = OpConstant %f32 42.0
+     %f32_n1 = OpConstant %f32 -1.0
+   %f32_v4_a = OpConstantComposite %vec4f %f32_42 %f32_42 %f32_42 %f32_n1
+   %f32_v4_b = OpConstantComposite %vec4f %f32_n1 %f32_n1 %f32_n1 %f32_42
+
+%_ptr_Output_f32 = OpTypePointer Output %f32
+%_ptr_Output_vec4f = OpTypePointer Output %vec4f
+  %_ptr_Output_str = OpTypePointer Output %str
+      %depth = OpVariable %_ptr_Output_f32 Output
+     %colors = OpVariable %_ptr_Output_str Output
+
+       %main = OpFunction %void None %fn_type
+ %main_start = OpLabel
+   %access_a = OpAccessChain %_ptr_Output_vec4f %colors %u32_0
+   %access_b = OpAccessChain %_ptr_Output_vec4f %colors %u32_1
+               OpStore %access_a %f32_v4_a
+               OpStore %access_b %f32_v4_b
+               OpStore %depth %f32_42
+               OpReturn
+               OpFunctionEnd
+)");
+    ASSERT_EQ(got, Success);
+    EXPECT_EQ(got, R"(
+tint_symbol_2 = struct @align(16) {
+  tint_symbol:vec4<f32> @offset(0)
+  tint_symbol_1:vec4<f32> @offset(16)
+}
+
+tint_symbol_4 = struct @align(16) {
+  tint_symbol_3:f32 @offset(0), @builtin(frag_depth)
+  tint_symbol:vec4<f32> @offset(16), @location(1)
+  tint_symbol_1:vec4<f32> @offset(32), @location(2), @interpolate(linear, center)
+}
+
+$B1: {  # root
+  %1:ptr<private, f32, read_write> = var
+  %2:ptr<private, tint_symbol_2, read_write> = var
+}
+
+%main_inner = func():void {
+  $B2: {
+    %4:ptr<private, vec4<f32>, read_write> = access %2, 0u
+    %5:ptr<private, vec4<f32>, read_write> = access %2, 1u
+    store %4, vec4<f32>(42.0f, 42.0f, 42.0f, -1.0f)
+    store %5, vec4<f32>(-1.0f, -1.0f, -1.0f, 42.0f)
+    store %1, 42.0f
+    ret
+  }
+}
+%main = @fragment func():tint_symbol_4 {
+  $B3: {
+    %7:void = call %main_inner
+    %8:f32 = load %1
+    %9:ptr<private, vec4<f32>, read_write> = access %2, 0u
+    %10:vec4<f32> = load %9
+    %11:ptr<private, vec4<f32>, read_write> = access %2, 1u
+    %12:vec4<f32> = load %11
+    %13:tint_symbol_4 = construct %8, %10, %12
+    ret %13
+  }
+}
+)");
+}
+
+TEST_F(SpirvReaderTest, SampleMask) {
+    auto got = Run(R"(
+               OpCapability Shader
+               OpMemoryModel Logical GLSL450
+               OpEntryPoint Fragment %main "main" %mask_in %mask_out
+               OpExecutionMode %main OriginUpperLeft
+               OpDecorate %mask_in BuiltIn SampleMask
+               OpDecorate %mask_out BuiltIn SampleMask
+       %void = OpTypeVoid
+    %fn_type = OpTypeFunction %void
+        %u32 = OpTypeInt 32 0
+      %u32_0 = OpConstant %u32 0
+      %u32_1 = OpConstant %u32 1
+    %arr_u32 = OpTypeArray %u32 %u32_1
+
+%_ptr_Input_u32 = OpTypePointer Input %u32
+%_ptr_Input_arr_u32 = OpTypePointer Input %arr_u32
+%_ptr_Output_u32 = OpTypePointer Output %u32
+%_ptr_Output_arr_u32 = OpTypePointer Output %arr_u32
+    %mask_in = OpVariable %_ptr_Input_arr_u32 Input
+   %mask_out = OpVariable %_ptr_Output_arr_u32 Output
+
+       %main = OpFunction %void None %fn_type
+ %main_start = OpLabel
+  %mask_in_0 = OpAccessChain %_ptr_Input_u32 %mask_in %u32_0
+%mask_in_val = OpLoad %u32 %mask_in_0
+   %plus_one = OpIAdd %u32 %mask_in_val %u32_1
+ %mask_out_0 = OpAccessChain %_ptr_Output_u32 %mask_out %u32_0
+               OpStore %mask_out_0 %plus_one
+               OpReturn
+               OpFunctionEnd
+)");
+    ASSERT_EQ(got, Success);
+    EXPECT_EQ(got, R"(
+$B1: {  # root
+  %1:ptr<private, array<u32, 1>, read_write> = var
+}
+
+%main_inner = func(%3:array<u32, 1>):void {
+  $B2: {
+    %4:u32 = access %3, 0u
+    %5:u32 = add %4, 1u
+    %6:ptr<private, u32, read_write> = access %1, 0u
+    store %6, %5
+    ret
+  }
+}
+%main = @fragment func(%8:u32 [@sample_mask]):u32 [@sample_mask] {
+  $B3: {
+    %9:array<u32, 1> = construct %8
+    %10:void = call %main_inner, %9
+    %11:array<u32, 1> = load %1
+    %12:u32 = access %11, 0u
+    ret %12
+  }
+}
+)");
+}
+
 }  // namespace
 }  // namespace tint::spirv::reader
diff --git a/src/tint/lang/spirv/validate/validate.h b/src/tint/lang/spirv/validate/validate.h
index 1e09477..9dab618 100644
--- a/src/tint/lang/spirv/validate/validate.h
+++ b/src/tint/lang/spirv/validate/validate.h
@@ -31,11 +31,6 @@
 #include "spirv-tools/libspirv.hpp"
 #include "src/tint/utils/result/result.h"
 
-// Forward declarations
-namespace tint {
-class Program;
-}  // namespace tint
-
 namespace tint::spirv::validate {
 
 /// Validate checks that the provided SPIR-V passes validation.
diff --git a/src/tint/lang/spirv/writer/ast_printer/assign_test.cc b/src/tint/lang/spirv/writer/ast_printer/assign_test.cc
index 24ad65b..7da01f4 100644
--- a/src/tint/lang/spirv/writer/ast_printer/assign_test.cc
+++ b/src/tint/lang/spirv/writer/ast_printer/assign_test.cc
@@ -66,7 +66,7 @@
 }
 
 TEST_F(SpirvASTPrinterTest, Assign_Var_OutsideFunction_IsError) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder pb;
 
diff --git a/src/tint/lang/spirv/writer/ast_printer/ast_if_test.cc b/src/tint/lang/spirv/writer/ast_printer/ast_if_test.cc
index 2404561..f8caeaa 100644
--- a/src/tint/lang/spirv/writer/ast_printer/ast_if_test.cc
+++ b/src/tint/lang/spirv/writer/ast_printer/ast_if_test.cc
@@ -64,7 +64,7 @@
     // if (true) {
     // }
 
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder pb;
 
diff --git a/src/tint/lang/spirv/writer/ast_printer/builtin_texture_test.cc b/src/tint/lang/spirv/writer/ast_printer/builtin_texture_test.cc
index 4e6f84b..2105c9f 100644
--- a/src/tint/lang/spirv/writer/ast_printer/builtin_texture_test.cc
+++ b/src/tint/lang/spirv/writer/ast_printer/builtin_texture_test.cc
@@ -3774,9 +3774,8 @@
     Validate(b);
 }
 
-// TODO(dsinclair): This generates two fatal errors, but EXPECT_DEATH can only handle 1
-TEST_P(BuiltinTextureTest, DISABLED_OutsideFunction_IsError) {
-    EXPECT_DEATH(
+TEST_P(BuiltinTextureTest, OutsideFunction_IsError) {
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             auto param = GetParam();
 
diff --git a/src/tint/lang/spirv/writer/ast_printer/function_attribute_test.cc b/src/tint/lang/spirv/writer/ast_printer/function_attribute_test.cc
index 27e9a6e..7c5fdfb 100644
--- a/src/tint/lang/spirv/writer/ast_printer/function_attribute_test.cc
+++ b/src/tint/lang/spirv/writer/ast_printer/function_attribute_test.cc
@@ -165,7 +165,7 @@
 }
 
 TEST_F(SpirvASTPrinterTest, Decoration_ExecutionMode_WorkgroupSize_OverridableConst) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder pb;
             pb.Override("width", pb.ty.i32(), pb.Call<i32>(2_i), pb.Id(7_u));
@@ -185,7 +185,7 @@
 }
 
 TEST_F(SpirvASTPrinterTest, Decoration_ExecutionMode_WorkgroupSize_LiteralAndConst) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder pb;
 
diff --git a/src/tint/lang/spirv/writer/ast_printer/ident_expression_test.cc b/src/tint/lang/spirv/writer/ast_printer/ident_expression_test.cc
index e65e8c9..54e18bb 100644
--- a/src/tint/lang/spirv/writer/ast_printer/ident_expression_test.cc
+++ b/src/tint/lang/spirv/writer/ast_printer/ident_expression_test.cc
@@ -37,7 +37,7 @@
 using SpirvASTPrinterTest = TestHelper;
 
 TEST_F(SpirvASTPrinterTest, IdentifierExpression_GlobalConst) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder pb;
 
diff --git a/src/tint/lang/spirv/writer/raise/builtin_polyfill.cc b/src/tint/lang/spirv/writer/raise/builtin_polyfill.cc
index b45beac..70808f6 100644
--- a/src/tint/lang/spirv/writer/raise/builtin_polyfill.cc
+++ b/src/tint/lang/spirv/writer/raise/builtin_polyfill.cc
@@ -114,10 +114,9 @@
 
         // Replace the builtins that we found.
         for (auto* builtin : worklist) {
-            core::ir::Value* replacement = nullptr;
             switch (builtin->Func()) {
                 case core::BuiltinFn::kArrayLength:
-                    replacement = ArrayLength(builtin);
+                    ArrayLength(builtin);
                     break;
                 case core::BuiltinFn::kAtomicAdd:
                 case core::BuiltinFn::kAtomicAnd:
@@ -130,30 +129,30 @@
                 case core::BuiltinFn::kAtomicStore:
                 case core::BuiltinFn::kAtomicSub:
                 case core::BuiltinFn::kAtomicXor:
-                    replacement = Atomic(builtin);
+                    Atomic(builtin);
                     break;
                 case core::BuiltinFn::kDot:
-                    replacement = Dot(builtin);
+                    Dot(builtin);
                     break;
                 case core::BuiltinFn::kDot4I8Packed:
                 case core::BuiltinFn::kDot4U8Packed:
-                    replacement = DotPacked4x8(builtin);
+                    DotPacked4x8(builtin);
                     break;
                 case core::BuiltinFn::kSelect:
-                    replacement = Select(builtin);
+                    Select(builtin);
                     break;
                 case core::BuiltinFn::kTextureDimensions:
-                    replacement = TextureDimensions(builtin);
+                    TextureDimensions(builtin);
                     break;
                 case core::BuiltinFn::kTextureGather:
                 case core::BuiltinFn::kTextureGatherCompare:
-                    replacement = TextureGather(builtin);
+                    TextureGather(builtin);
                     break;
                 case core::BuiltinFn::kTextureLoad:
-                    replacement = TextureLoad(builtin);
+                    TextureLoad(builtin);
                     break;
                 case core::BuiltinFn::kTextureNumLayers:
-                    replacement = TextureNumLayers(builtin);
+                    TextureNumLayers(builtin);
                     break;
                 case core::BuiltinFn::kTextureSample:
                 case core::BuiltinFn::kTextureSampleBias:
@@ -161,25 +160,17 @@
                 case core::BuiltinFn::kTextureSampleCompareLevel:
                 case core::BuiltinFn::kTextureSampleGrad:
                 case core::BuiltinFn::kTextureSampleLevel:
-                    replacement = TextureSample(builtin);
+                    TextureSample(builtin);
                     break;
                 case core::BuiltinFn::kTextureStore:
-                    replacement = TextureStore(builtin);
+                    TextureStore(builtin);
                     break;
                 case core::BuiltinFn::kQuantizeToF16:
-                    replacement = QuantizeToF16Vec(builtin);
+                    QuantizeToF16Vec(builtin);
                     break;
                 default:
                     break;
             }
-            TINT_ASSERT(replacement);
-
-            // Replace the old builtin result with the new value.
-            if (auto name = ir.NameOf(builtin->Result(0))) {
-                ir.SetName(replacement, name);
-            }
-            builtin->Result(0)->ReplaceAllUsesWith(replacement);
-            builtin->Destroy();
         }
     }
 
@@ -192,8 +183,7 @@
 
     /// Handle an `arrayLength()` builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* ArrayLength(core::ir::CoreBuiltinCall* builtin) {
+    void ArrayLength(core::ir::CoreBuiltinCall* builtin) {
         // Strip away any let instructions to get to the original struct member access instruction.
         auto* ptr = builtin->Args()[0]->As<core::ir::InstructionResult>();
         while (auto* let = tint::As<core::ir::Let>(ptr->Instruction())) {
@@ -208,17 +198,16 @@
         auto* const_idx = access->Indices()[0]->As<core::ir::Constant>();
 
         // Replace the builtin call with a call to the spirv.array_length intrinsic.
-        auto* call = b.Call<spirv::ir::BuiltinCall>(
-            builtin->Result(0)->Type(), spirv::BuiltinFn::kArrayLength,
+        auto* call = b.CallWithResult<spirv::ir::BuiltinCall>(
+            builtin->DetachResult(), spirv::BuiltinFn::kArrayLength,
             Vector{access->Object(), Literal(u32(const_idx->Value()->ValueAs<uint32_t>()))});
         call->InsertBefore(builtin);
-        return call->Result(0);
+        builtin->Destroy();
     }
 
     /// Handle an atomic*() builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* Atomic(core::ir::CoreBuiltinCall* builtin) {
+    void Atomic(core::ir::CoreBuiltinCall* builtin) {
         auto* result_ty = builtin->Result(0)->Type();
 
         auto* pointer = builtin->Args()[0];
@@ -235,27 +224,29 @@
         auto* memory_semantics = b.Constant(u32(SpvMemorySemanticsMaskNone));
 
         // Helper to build the builtin call with the common operands.
-        auto build = [&](const core::type::Type* type, enum spirv::BuiltinFn builtin_fn) {
-            return b.Call<spirv::ir::BuiltinCall>(type, builtin_fn, pointer, memory,
-                                                  memory_semantics);
+        auto build = [&](enum spirv::BuiltinFn builtin_fn) {
+            return b.CallWithResult<spirv::ir::BuiltinCall>(builtin->DetachResult(), builtin_fn,
+                                                            pointer, memory, memory_semantics);
         };
 
         // Create the replacement call instruction.
         core::ir::Call* call = nullptr;
         switch (builtin->Func()) {
             case core::BuiltinFn::kAtomicAdd:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicIadd);
+                call = build(spirv::BuiltinFn::kAtomicIadd);
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicAnd:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicAnd);
+                call = build(spirv::BuiltinFn::kAtomicAnd);
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicCompareExchangeWeak: {
                 auto* cmp = builtin->Args()[1];
                 auto* value = builtin->Args()[2];
                 auto* int_ty = value->Type();
-                call = build(int_ty, spirv::BuiltinFn::kAtomicCompareExchange);
+                call =
+                    b.Call<spirv::ir::BuiltinCall>(int_ty, spirv::BuiltinFn::kAtomicCompareExchange,
+                                                   pointer, memory, memory_semantics);
                 call->AppendArg(memory_semantics);
                 call->AppendArg(value);
                 call->AppendArg(cmp);
@@ -267,62 +258,60 @@
                 compare->InsertBefore(builtin);
 
                 // Construct the atomicCompareExchange result structure.
-                call = b.Construct(
-                    core::type::CreateAtomicCompareExchangeResult(ty, ir.symbols, int_ty),
-                    Vector{original, compare->Result(0)});
+                call = b.ConstructWithResult(builtin->DetachResult(),
+                                             Vector{original, compare->Result(0)});
                 break;
             }
             case core::BuiltinFn::kAtomicExchange:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicExchange);
+                call = build(spirv::BuiltinFn::kAtomicExchange);
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicLoad:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicLoad);
+                call = build(spirv::BuiltinFn::kAtomicLoad);
                 break;
             case core::BuiltinFn::kAtomicOr:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicOr);
+                call = build(spirv::BuiltinFn::kAtomicOr);
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicMax:
                 if (result_ty->is_signed_integer_scalar()) {
-                    call = build(result_ty, spirv::BuiltinFn::kAtomicSmax);
+                    call = build(spirv::BuiltinFn::kAtomicSmax);
                 } else {
-                    call = build(result_ty, spirv::BuiltinFn::kAtomicUmax);
+                    call = build(spirv::BuiltinFn::kAtomicUmax);
                 }
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicMin:
                 if (result_ty->is_signed_integer_scalar()) {
-                    call = build(result_ty, spirv::BuiltinFn::kAtomicSmin);
+                    call = build(spirv::BuiltinFn::kAtomicSmin);
                 } else {
-                    call = build(result_ty, spirv::BuiltinFn::kAtomicUmin);
+                    call = build(spirv::BuiltinFn::kAtomicUmin);
                 }
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicStore:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicStore);
+                call = build(spirv::BuiltinFn::kAtomicStore);
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicSub:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicIsub);
+                call = build(spirv::BuiltinFn::kAtomicIsub);
                 call->AppendArg(builtin->Args()[1]);
                 break;
             case core::BuiltinFn::kAtomicXor:
-                call = build(result_ty, spirv::BuiltinFn::kAtomicXor);
+                call = build(spirv::BuiltinFn::kAtomicXor);
                 call->AppendArg(builtin->Args()[1]);
                 break;
             default:
-                return nullptr;
+                TINT_UNREACHABLE() << "unhandled atomic builtin";
         }
 
         call->InsertBefore(builtin);
-        return call->Result(0);
+        builtin->Destroy();
     }
 
     /// Handle a `dot()` builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* Dot(core::ir::CoreBuiltinCall* builtin) {
+    void Dot(core::ir::CoreBuiltinCall* builtin) {
         // OpDot only supports floating point operands, so we need to polyfill the integer case.
         // TODO(crbug.com/tint/1267): If SPV_KHR_integer_dot_product is supported, use that instead.
         if (builtin->Result(0)->Type()->is_integer_scalar()) {
@@ -344,38 +333,38 @@
                     }
                 });
             }
-            return sum->Result(0);
+            sum->SetResults(Vector{builtin->DetachResult()});
+            builtin->Destroy();
+            return;
         }
 
         // Replace the builtin call with a call to the spirv.dot intrinsic.
         auto args = Vector<core::ir::Value*, 4>(builtin->Args());
-        auto* call = b.Call<spirv::ir::BuiltinCall>(builtin->Result(0)->Type(),
-                                                    spirv::BuiltinFn::kDot, std::move(args));
+        auto* call = b.CallWithResult<spirv::ir::BuiltinCall>(
+            builtin->DetachResult(), spirv::BuiltinFn::kDot, std::move(args));
         call->InsertBefore(builtin);
-        return call->Result(0);
+        builtin->Destroy();
     }
 
     /// Handle a `dot4{I,U}8Packed()` builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* DotPacked4x8(core::ir::CoreBuiltinCall* builtin) {
+    void DotPacked4x8(core::ir::CoreBuiltinCall* builtin) {
         // Replace the builtin call with a call to the spirv.{s,u}dot intrinsic.
-        auto* type = builtin->Result(0)->Type();
         auto is_signed = builtin->Func() == core::BuiltinFn::kDot4I8Packed;
         auto inst = is_signed ? spirv::BuiltinFn::kSdot : spirv::BuiltinFn::kUdot;
 
         auto args = Vector<core::ir::Value*, 3>(builtin->Args());
         args.Push(Literal(u32(SpvPackedVectorFormatPackedVectorFormat4x8Bit)));
 
-        auto* call = b.Call<spirv::ir::BuiltinCall>(type, inst, std::move(args));
+        auto* call = b.CallWithResult<spirv::ir::BuiltinCall>(builtin->DetachResult(), inst,
+                                                              std::move(args));
         call->InsertBefore(builtin);
-        return call->Result(0);
+        builtin->Destroy();
     }
 
     /// Handle a `select()` builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* Select(core::ir::CoreBuiltinCall* builtin) {
+    void Select(core::ir::CoreBuiltinCall* builtin) {
         // Argument order is different in SPIR-V: (condition, true_operand, false_operand).
         Vector<core::ir::Value*, 4> args = {
             builtin->Args()[2],
@@ -397,10 +386,10 @@
         }
 
         // Replace the builtin call with a call to the spirv.select intrinsic.
-        auto* call = b.Call<spirv::ir::BuiltinCall>(builtin->Result(0)->Type(),
-                                                    spirv::BuiltinFn::kSelect, std::move(args));
+        auto* call = b.CallWithResult<spirv::ir::BuiltinCall>(
+            builtin->DetachResult(), spirv::BuiltinFn::kSelect, std::move(args));
         call->InsertBefore(builtin);
-        return call->Result(0);
+        builtin->Destroy();
     }
 
     /// ImageOperands represents the optional image operands for an image instruction.
@@ -494,8 +483,7 @@
 
     /// Handle a textureSample*() builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* TextureSample(core::ir::CoreBuiltinCall* builtin) {
+    void TextureSample(core::ir::CoreBuiltinCall* builtin) {
         // Helper to get the next argument from the call, or nullptr if there are no more arguments.
         uint32_t arg_idx = 0;
         auto next_arg = [&]() {
@@ -520,7 +508,7 @@
         }
 
         // Determine which SPIR-V function to use and which optional image operands are needed.
-        enum spirv::BuiltinFn function;
+        enum spirv::BuiltinFn function = BuiltinFn::kNone;
         core::ir::Value* depth = nullptr;
         ImageOperands operands;
         switch (builtin->Func()) {
@@ -556,7 +544,7 @@
                 operands.offset = next_arg();
                 break;
             default:
-                return nullptr;
+                TINT_UNREACHABLE() << "unhandled texture sample builtin";
         }
 
         // Start building the argument list for the function.
@@ -575,28 +563,25 @@
         // Call the function.
         // If this is a depth comparison, the result is always f32, otherwise vec4f.
         auto* result_ty = depth ? static_cast<const core::type::Type*>(ty.f32()) : ty.vec4<f32>();
-        auto* texture_call =
+        core::ir::Instruction* result =
             b.Call<spirv::ir::BuiltinCall>(result_ty, function, std::move(function_args));
-        texture_call->InsertBefore(builtin);
-
-        auto* result = texture_call->Result(0);
+        result->InsertBefore(builtin);
 
         // If this is not a depth comparison but we are sampling a depth texture, extract the first
         // component to get the scalar f32 that SPIR-V expects.
         if (!depth &&
             texture_ty->IsAnyOf<core::type::DepthTexture, core::type::DepthMultisampledTexture>()) {
-            auto* extract = b.Access(ty.f32(), result, 0_u);
-            extract->InsertBefore(builtin);
-            result = extract->Result(0);
+            result = b.Access(ty.f32(), result, 0_u);
+            result->InsertBefore(builtin);
         }
 
-        return result;
+        result->SetResults(Vector{builtin->DetachResult()});
+        builtin->Destroy();
     }
 
     /// Handle a textureGather*() builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* TextureGather(core::ir::CoreBuiltinCall* builtin) {
+    void TextureGather(core::ir::CoreBuiltinCall* builtin) {
         // Helper to get the next argument from the call, or nullptr if there are no more arguments.
         uint32_t arg_idx = 0;
         auto next_arg = [&]() {
@@ -628,7 +613,7 @@
         }
 
         // Determine which SPIR-V function to use and which optional image operands are needed.
-        enum spirv::BuiltinFn function;
+        enum spirv::BuiltinFn function = BuiltinFn::kNone;
         core::ir::Value* depth = nullptr;
         ImageOperands operands;
         switch (builtin->Func()) {
@@ -642,7 +627,7 @@
                 operands.offset = next_arg();
                 break;
             default:
-                return nullptr;
+                TINT_UNIMPLEMENTED() << "unhandled texture gather builtin";
         }
 
         // Start building the argument list for the function.
@@ -661,17 +646,15 @@
         AppendImageOperands(operands, function_args, builtin, /* requires_float_lod */ true);
 
         // Call the function.
-        auto* result_ty = builtin->Result(0)->Type();
-        auto* texture_call =
-            b.Call<spirv::ir::BuiltinCall>(result_ty, function, std::move(function_args));
+        auto* texture_call = b.CallWithResult<spirv::ir::BuiltinCall>(
+            builtin->DetachResult(), function, std::move(function_args));
         texture_call->InsertBefore(builtin);
-        return texture_call->Result(0);
+        builtin->Destroy();
     }
 
     /// Handle a textureLoad() builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* TextureLoad(core::ir::CoreBuiltinCall* builtin) {
+    void TextureLoad(core::ir::CoreBuiltinCall* builtin) {
         // Helper to get the next argument from the call, or nullptr if there are no more arguments.
         uint32_t arg_idx = 0;
         auto next_arg = [&]() {
@@ -713,25 +696,23 @@
         }
         auto kind = texture_ty->Is<core::type::StorageTexture>() ? spirv::BuiltinFn::kImageRead
                                                                  : spirv::BuiltinFn::kImageFetch;
-        auto* texture_call =
+        core::ir::Instruction* result =
             b.Call<spirv::ir::BuiltinCall>(result_ty, kind, std::move(builtin_args));
-        texture_call->InsertBefore(builtin);
-        auto* result = texture_call->Result(0);
+        result->InsertBefore(builtin);
 
         // If we are expecting a scalar result, extract the first component.
         if (expects_scalar_result) {
-            auto* extract = b.Access(ty.f32(), result, 0_u);
-            extract->InsertBefore(builtin);
-            result = extract->Result(0);
+            result = b.Access(ty.f32(), result, 0_u);
+            result->InsertBefore(builtin);
         }
 
-        return result;
+        result->SetResults(Vector{builtin->DetachResult()});
+        builtin->Destroy();
     }
 
     /// Handle a textureStore() builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* TextureStore(core::ir::CoreBuiltinCall* builtin) {
+    void TextureStore(core::ir::CoreBuiltinCall* builtin) {
         // Helper to get the next argument from the call, or nullptr if there are no more arguments.
         uint32_t arg_idx = 0;
         auto next_arg = [&]() {
@@ -764,13 +745,12 @@
         auto* texture_call = b.Call<spirv::ir::BuiltinCall>(
             ty.void_(), spirv::BuiltinFn::kImageWrite, std::move(function_args));
         texture_call->InsertBefore(builtin);
-        return texture_call->Result(0);
+        builtin->Destroy();
     }
 
     /// Handle a textureDimensions() builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* TextureDimensions(core::ir::CoreBuiltinCall* builtin) {
+    void TextureDimensions(core::ir::CoreBuiltinCall* builtin) {
         // Helper to get the next argument from the call, or nullptr if there are no more arguments.
         uint32_t arg_idx = 0;
         auto next_arg = [&]() {
@@ -807,26 +787,23 @@
         }
 
         // Call the function.
-        auto* texture_call =
+        core::ir::Instruction* result =
             b.Call<spirv::ir::BuiltinCall>(result_ty, function, std::move(function_args));
-        texture_call->InsertBefore(builtin);
-
-        auto* result = texture_call->Result(0);
+        result->InsertBefore(builtin);
 
         // Swizzle the first two components from the result for arrayed textures.
         if (core::type::IsTextureArray(texture_ty->dim())) {
-            auto* swizzle = b.Swizzle(builtin->Result(0)->Type(), result, {0, 1});
-            swizzle->InsertBefore(builtin);
-            result = swizzle->Result(0);
+            result = b.Swizzle(builtin->Result(0)->Type(), result, {0, 1});
+            result->InsertBefore(builtin);
         }
 
-        return result;
+        result->SetResults(Vector{builtin->DetachResult()});
+        builtin->Destroy();
     }
 
     /// Handle a textureNumLayers() builtin.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* TextureNumLayers(core::ir::CoreBuiltinCall* builtin) {
+    void TextureNumLayers(core::ir::CoreBuiltinCall* builtin) {
         auto* texture = builtin->Args()[0];
         auto* texture_ty = texture->Type()->As<core::type::Texture>();
 
@@ -850,16 +827,15 @@
         texture_call->InsertBefore(builtin);
 
         // Extract the third component to get the number of array layers.
-        auto* extract = b.Access(ty.u32(), texture_call->Result(0), 2_u);
+        auto* extract = b.AccessWithResult(builtin->DetachResult(), texture_call->Result(0), 2_u);
         extract->InsertBefore(builtin);
-        return extract->Result(0);
+        builtin->Destroy();
     }
 
     /// Scalarize the vector form of a `quantizeToF16()` builtin.
     /// See crbug.com/tint/1741.
     /// @param builtin the builtin call instruction
-    /// @returns the replacement value
-    core::ir::Value* QuantizeToF16Vec(core::ir::CoreBuiltinCall* builtin) {
+    void QuantizeToF16Vec(core::ir::CoreBuiltinCall* builtin) {
         auto* arg = builtin->Args()[0];
         auto* vec = arg->Type()->As<core::type::Vector>();
         TINT_ASSERT(vec);
@@ -873,9 +849,9 @@
             el->InsertBefore(builtin);
             scalar_call->InsertBefore(builtin);
         }
-        auto* construct = b.Construct(vec, std::move(args));
+        auto* construct = b.ConstructWithResult(builtin->DetachResult(), std::move(args));
         construct->InsertBefore(builtin);
-        return construct->Result(0);
+        builtin->Destroy();
     }
 };
 
diff --git a/src/tint/lang/spirv/writer/raise/expand_implicit_splats.cc b/src/tint/lang/spirv/writer/raise/expand_implicit_splats.cc
index 3b650a7..c5359f2 100644
--- a/src/tint/lang/spirv/writer/raise/expand_implicit_splats.cc
+++ b/src/tint/lang/spirv/writer/raise/expand_implicit_splats.cc
@@ -107,8 +107,8 @@
         auto* result_ty = binary->Result(0)->Type();
         if (result_ty->is_float_vector() && binary->Op() == core::BinaryOp::kMultiply) {
             // Use OpVectorTimesScalar for floating point multiply.
-            auto* vts =
-                b.Call<spirv::ir::BuiltinCall>(result_ty, spirv::BuiltinFn::kVectorTimesScalar);
+            auto* vts = b.CallWithResult<spirv::ir::BuiltinCall>(
+                binary->DetachResult(), spirv::BuiltinFn::kVectorTimesScalar);
             if (binary->LHS()->Type()->Is<core::type::Scalar>()) {
                 vts->AppendArg(binary->RHS());
                 vts->AppendArg(binary->LHS());
@@ -119,7 +119,6 @@
             if (auto name = ir.NameOf(binary)) {
                 ir.SetName(vts->Result(0), name);
             }
-            binary->Result(0)->ReplaceAllUsesWith(vts->Result(0));
             binary->ReplaceWith(vts);
             binary->Destroy();
         } else {
diff --git a/src/tint/lang/spirv/writer/raise/handle_matrix_arithmetic.cc b/src/tint/lang/spirv/writer/raise/handle_matrix_arithmetic.cc
index 487bac0..3091c09 100644
--- a/src/tint/lang/spirv/writer/raise/handle_matrix_arithmetic.cc
+++ b/src/tint/lang/spirv/writer/raise/handle_matrix_arithmetic.cc
@@ -77,65 +77,63 @@
         auto* rhs_ty = rhs->Type();
         auto* ty = binary->Result(0)->Type();
 
-        // Helper to replace the instruction with a new one.
-        auto replace = [&](core::ir::Instruction* inst) {
-            if (auto name = ir.NameOf(binary)) {
-                ir.SetName(inst->Result(0), name);
-            }
-            binary->Result(0)->ReplaceAllUsesWith(inst->Result(0));
-            binary->ReplaceWith(inst);
-            binary->Destroy();
-        };
-
-        // Helper to replace the instruction with a column-wise operation.
-        auto column_wise = [&](auto op) {
-            auto* mat = ty->As<core::type::Matrix>();
-            Vector<core::ir::Value*, 4> args;
-            for (uint32_t col = 0; col < mat->columns(); col++) {
-                b.InsertBefore(binary, [&] {
+        b.InsertBefore(binary, [&] {
+            // Helper to replace the instruction with a column-wise operation.
+            auto column_wise = [&](auto op) {
+                auto* mat = ty->As<core::type::Matrix>();
+                Vector<core::ir::Value*, 4> args;
+                for (uint32_t col = 0; col < mat->columns(); col++) {
                     auto* lhs_col = b.Access(mat->ColumnType(), lhs, u32(col));
                     auto* rhs_col = b.Access(mat->ColumnType(), rhs, u32(col));
                     auto* add = b.Binary(op, mat->ColumnType(), lhs_col, rhs_col);
                     args.Push(add->Result(0));
-                });
-            }
-            replace(b.Construct(ty, std::move(args)));
-        };
-
-        switch (binary->Op()) {
-            case core::BinaryOp::kAdd:
-                column_wise(core::BinaryOp::kAdd);
-                break;
-            case core::BinaryOp::kSubtract:
-                column_wise(core::BinaryOp::kSubtract);
-                break;
-            case core::BinaryOp::kMultiply:
-                // Select the SPIR-V intrinsic that corresponds to the operation being performed.
-                if (lhs_ty->Is<core::type::Matrix>()) {
-                    if (rhs_ty->Is<core::type::Scalar>()) {
-                        replace(b.Call<spirv::ir::BuiltinCall>(
-                            ty, spirv::BuiltinFn::kMatrixTimesScalar, lhs, rhs));
-                    } else if (rhs_ty->Is<core::type::Vector>()) {
-                        replace(b.Call<spirv::ir::BuiltinCall>(
-                            ty, spirv::BuiltinFn::kMatrixTimesVector, lhs, rhs));
-                    } else if (rhs_ty->Is<core::type::Matrix>()) {
-                        replace(b.Call<spirv::ir::BuiltinCall>(
-                            ty, spirv::BuiltinFn::kMatrixTimesMatrix, lhs, rhs));
-                    }
-                } else {
-                    if (lhs_ty->Is<core::type::Scalar>()) {
-                        replace(b.Call<spirv::ir::BuiltinCall>(
-                            ty, spirv::BuiltinFn::kMatrixTimesScalar, rhs, lhs));
-                    } else if (lhs_ty->Is<core::type::Vector>()) {
-                        replace(b.Call<spirv::ir::BuiltinCall>(
-                            ty, spirv::BuiltinFn::kVectorTimesMatrix, lhs, rhs));
-                    }
                 }
-                break;
+                b.ConstructWithResult(binary->DetachResult(), std::move(args));
+            };
 
-            default:
-                TINT_UNREACHABLE() << "unhandled matrix arithmetic instruction";
-        }
+            switch (binary->Op()) {
+                case core::BinaryOp::kAdd:
+                    column_wise(core::BinaryOp::kAdd);
+                    break;
+                case core::BinaryOp::kSubtract:
+                    column_wise(core::BinaryOp::kSubtract);
+                    break;
+                case core::BinaryOp::kMultiply:
+                    // Select the SPIR-V intrinsic that corresponds to the operation being
+                    // performed.
+                    if (lhs_ty->Is<core::type::Matrix>()) {
+                        if (rhs_ty->Is<core::type::Scalar>()) {
+                            b.CallWithResult<spirv::ir::BuiltinCall>(
+                                binary->DetachResult(), spirv::BuiltinFn::kMatrixTimesScalar, lhs,
+                                rhs);
+                        } else if (rhs_ty->Is<core::type::Vector>()) {
+                            b.CallWithResult<spirv::ir::BuiltinCall>(
+                                binary->DetachResult(), spirv::BuiltinFn::kMatrixTimesVector, lhs,
+                                rhs);
+                        } else if (rhs_ty->Is<core::type::Matrix>()) {
+                            b.CallWithResult<spirv::ir::BuiltinCall>(
+                                binary->DetachResult(), spirv::BuiltinFn::kMatrixTimesMatrix, lhs,
+                                rhs);
+                        }
+                    } else {
+                        if (lhs_ty->Is<core::type::Scalar>()) {
+                            b.CallWithResult<spirv::ir::BuiltinCall>(
+                                binary->DetachResult(), spirv::BuiltinFn::kMatrixTimesScalar, rhs,
+                                lhs);
+                        } else if (lhs_ty->Is<core::type::Vector>()) {
+                            b.CallWithResult<spirv::ir::BuiltinCall>(
+                                binary->DetachResult(), spirv::BuiltinFn::kVectorTimesMatrix, lhs,
+                                rhs);
+                        }
+                    }
+                    break;
+
+                default:
+                    TINT_UNREACHABLE() << "unhandled matrix arithmetic instruction";
+            }
+        });
+
+        binary->Destroy();
     }
 
     /// Replace a matrix convert instruction.
@@ -145,23 +143,19 @@
         auto* in_mat = arg->Type()->As<core::type::Matrix>();
         auto* out_mat = convert->Result(0)->Type()->As<core::type::Matrix>();
 
-        // Extract and convert each column separately.
-        Vector<core::ir::Value*, 4> args;
-        for (uint32_t c = 0; c < out_mat->columns(); c++) {
-            b.InsertBefore(convert, [&] {
+        b.InsertBefore(convert, [&] {
+            // Extract and convert each column separately.
+            Vector<core::ir::Value*, 4> args;
+            for (uint32_t c = 0; c < out_mat->columns(); c++) {
                 auto* col = b.Access(in_mat->ColumnType(), arg, u32(c));
                 auto* new_col = b.Convert(out_mat->ColumnType(), col);
                 args.Push(new_col->Result(0));
-            });
-        }
+            }
 
-        // Reconstruct the result matrix from the converted columns.
-        auto* construct = b.Construct(out_mat, std::move(args));
-        if (auto name = ir.NameOf(convert)) {
-            ir.SetName(construct->Result(0), name);
-        }
-        convert->Result(0)->ReplaceAllUsesWith(construct->Result(0));
-        convert->ReplaceWith(construct);
+            // Reconstruct the result matrix from the converted columns.
+            b.ConstructWithResult(convert->DetachResult(), std::move(args));
+        });
+
         convert->Destroy();
     }
 };
diff --git a/src/tint/lang/spirv/writer/raise/var_for_dynamic_index.cc b/src/tint/lang/spirv/writer/raise/var_for_dynamic_index.cc
index 409a52e..49655d5 100644
--- a/src/tint/lang/spirv/writer/raise/var_for_dynamic_index.cc
+++ b/src/tint/lang/spirv/writer/raise/var_for_dynamic_index.cc
@@ -237,13 +237,11 @@
 
             core::ir::Instruction* load = nullptr;
             if (to_replace.vector_access_type) {
-                load = b.LoadVectorElement(new_access->Result(0), vector_index);
+                load = b.LoadVectorElementWithResult(access->DetachResult(), new_access->Result(0),
+                                                     vector_index);
             } else {
-                load = b.Load(new_access);
+                load = b.LoadWithResult(access->DetachResult(), new_access);
             }
-
-            // Replace all uses of the old access instruction with the loaded result.
-            access->Result(0)->ReplaceAllUsesWith(load->Result(0));
             access->ReplaceWith(load);
             access->Destroy();
         }
diff --git a/src/tint/lang/spirv/writer/writer_ir_fuzz.cc b/src/tint/lang/spirv/writer/writer_ir_fuzz.cc
index dcc848c..483dd77 100644
--- a/src/tint/lang/spirv/writer/writer_ir_fuzz.cc
+++ b/src/tint/lang/spirv/writer/writer_ir_fuzz.cc
@@ -28,6 +28,7 @@
 #include "src/tint/lang/spirv/writer/writer.h"
 
 #include "src/tint/cmd/fuzz/ir/fuzz.h"
+#include "src/tint/lang/core/ir/disassembly.h"
 #include "src/tint/lang/spirv/validate/validate.h"
 #include "src/tint/lang/spirv/writer/helpers/generate_bindings.h"
 
@@ -43,8 +44,10 @@
     auto& spirv = output->spirv;
     if (auto res = validate::Validate(Slice(spirv.data(), spirv.size()), SPV_ENV_VULKAN_1_1);
         res != Success) {
-        TINT_ICE() << "Output of SPIR-V writer failed to validate with SPIR-V Tools\n"
-                   << res.Failure();
+        TINT_ICE() << "output of SPIR-V writer failed to validate with SPIR-V Tools\n"
+                   << res.Failure() << "\n\n"
+                   << "IR:\n"
+                   << core::ir::Disassemble(module).Plain();
     }
 }
 
diff --git a/src/tint/lang/wgsl/ast/assignment_statement_test.cc b/src/tint/lang/wgsl/ast/assignment_statement_test.cc
index 6c51920..7ba43d1 100644
--- a/src/tint/lang/wgsl/ast/assignment_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/assignment_statement_test.cc
@@ -64,7 +64,7 @@
 }
 
 TEST_F(AssignmentStatementTest, Assert_Null_LHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<AssignmentStatement>(nullptr, b.Expr(1_i));
@@ -73,7 +73,7 @@
 }
 
 TEST_F(AssignmentStatementTest, Assert_Null_RHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<AssignmentStatement>(b.Expr(1_i), nullptr);
@@ -82,7 +82,7 @@
 }
 
 TEST_F(AssignmentStatementTest, Assert_DifferentGenerationID_LHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -92,7 +92,7 @@
 }
 
 TEST_F(AssignmentStatementTest, Assert_DifferentGenerationID_RHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/binary_expression_test.cc b/src/tint/lang/wgsl/ast/binary_expression_test.cc
index 34e6e58..7dffd7b 100644
--- a/src/tint/lang/wgsl/ast/binary_expression_test.cc
+++ b/src/tint/lang/wgsl/ast/binary_expression_test.cc
@@ -62,7 +62,7 @@
 }
 
 TEST_F(BinaryExpressionTest, Assert_Null_LHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<BinaryExpression>(core::BinaryOp::kEqual, nullptr, b.Expr("rhs"));
@@ -71,7 +71,7 @@
 }
 
 TEST_F(BinaryExpressionTest, Assert_Null_RHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<BinaryExpression>(core::BinaryOp::kEqual, b.Expr("lhs"), nullptr);
@@ -80,7 +80,7 @@
 }
 
 TEST_F(BinaryExpressionTest, Assert_DifferentGenerationID_LHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -90,7 +90,7 @@
 }
 
 TEST_F(BinaryExpressionTest, Assert_DifferentGenerationID_RHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/block_statement_test.cc b/src/tint/lang/wgsl/ast/block_statement_test.cc
index 6e00eaa..8540432 100644
--- a/src/tint/lang/wgsl/ast/block_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/block_statement_test.cc
@@ -72,7 +72,7 @@
 }
 
 TEST_F(BlockStatementTest, Assert_Null_Statement) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<BlockStatement>(tint::Vector<const Statement*, 1>{nullptr}, tint::Empty);
@@ -81,7 +81,7 @@
 }
 
 TEST_F(BlockStatementTest, Assert_DifferentGenerationID_Statement) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/break_if_statement_test.cc b/src/tint/lang/wgsl/ast/break_if_statement_test.cc
index 4c59976..c5e3e4b 100644
--- a/src/tint/lang/wgsl/ast/break_if_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/break_if_statement_test.cc
@@ -48,7 +48,7 @@
 }
 
 TEST_F(BreakIfStatementTest, Assert_Null_Condition) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.BreakIf(nullptr);
@@ -57,7 +57,7 @@
 }
 
 TEST_F(BreakIfStatementTest, Assert_DifferentGenerationID_Cond) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/builtin_attribute_test.cc b/src/tint/lang/wgsl/ast/builtin_attribute_test.cc
index 4bd547b..6a56995 100644
--- a/src/tint/lang/wgsl/ast/builtin_attribute_test.cc
+++ b/src/tint/lang/wgsl/ast/builtin_attribute_test.cc
@@ -39,7 +39,7 @@
 }
 
 TEST_F(BuiltinAttributeTest, Assert_Null_Builtin) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Builtin(nullptr);
@@ -48,7 +48,7 @@
 }
 
 TEST_F(BuiltinAttributeTest, Assert_DifferentGenerationID_Builtin) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/call_expression_test.cc b/src/tint/lang/wgsl/ast/call_expression_test.cc
index 7e6cab8..90bbc34 100644
--- a/src/tint/lang/wgsl/ast/call_expression_test.cc
+++ b/src/tint/lang/wgsl/ast/call_expression_test.cc
@@ -91,7 +91,7 @@
 }
 
 TEST_F(CallExpressionTest, Assert_Null_Identifier) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Call(static_cast<Identifier*>(nullptr));
@@ -100,7 +100,7 @@
 }
 
 TEST_F(CallExpressionTest, Assert_Null_Param) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Call(b.Ident("func"), tint::Vector{
@@ -113,7 +113,7 @@
 }
 
 TEST_F(CallExpressionTest, Assert_DifferentGenerationID_Identifier) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -123,7 +123,7 @@
 }
 
 TEST_F(CallExpressionTest, Assert_DifferentGenerationID_Type) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -133,7 +133,7 @@
 }
 
 TEST_F(CallExpressionTest, Assert_DifferentGenerationID_Param) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/call_statement_test.cc b/src/tint/lang/wgsl/ast/call_statement_test.cc
index e9e8169..3c1a47f 100644
--- a/src/tint/lang/wgsl/ast/call_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/call_statement_test.cc
@@ -47,7 +47,7 @@
 }
 
 TEST_F(CallStatementTest, Assert_Null_Call) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.CallStmt(nullptr);
@@ -56,7 +56,7 @@
 }
 
 TEST_F(CallStatementTest, Assert_DifferentGenerationID_Call) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/case_statement_test.cc b/src/tint/lang/wgsl/ast/case_statement_test.cc
index 60df861..6425ac2 100644
--- a/src/tint/lang/wgsl/ast/case_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/case_statement_test.cc
@@ -99,7 +99,7 @@
 }
 
 TEST_F(CaseStatementTest, Assert_Null_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<CaseStatement>(tint::Vector{b.DefaultCaseSelector()}, nullptr);
@@ -108,7 +108,7 @@
 }
 
 TEST_F(CaseStatementTest, Assert_Null_Selector) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<CaseStatement>(tint::Vector<const ast::CaseSelector*, 1>{nullptr},
@@ -118,7 +118,7 @@
 }
 
 TEST_F(CaseStatementTest, Assert_DifferentGenerationID_Call) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -129,7 +129,7 @@
 }
 
 TEST_F(CaseStatementTest, Assert_DifferentGenerationID_Selector) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/clone_context_test.cc b/src/tint/lang/wgsl/ast/clone_context_test.cc
index 6bc1e6e..4d70dbc 100644
--- a/src/tint/lang/wgsl/ast/clone_context_test.cc
+++ b/src/tint/lang/wgsl/ast/clone_context_test.cc
@@ -1053,7 +1053,7 @@
 TEST_F(ASTCloneContextTestNodeTest, CloneWithReplaceAll_SameTypeTwice) {
     std::string Testnode_name = TypeInfo::Of<TestNode>().name;
 
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1070,7 +1070,7 @@
     std::string Testnode_name = TypeInfo::Of<TestNode>().name;
     std::string replaceable_name = TypeInfo::Of<Replaceable>().name;
 
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1087,7 +1087,7 @@
     std::string Testnode_name = TypeInfo::Of<TestNode>().name;
     std::string replaceable_name = TypeInfo::Of<Replaceable>().name;
 
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1103,7 +1103,7 @@
 using ASTCloneContextTest = ::testing::Test;
 
 TEST_F(ASTCloneContextTest, CloneWithReplaceAll_SymbolsTwice) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1181,7 +1181,7 @@
 }
 
 TEST_F(ASTCloneContextTest, GenerationIDs_Clone_ObjectNotOwnedBySrc) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder dst;
             Program src(ProgramBuilder{});
@@ -1194,7 +1194,7 @@
 }
 
 TEST_F(ASTCloneContextTest, GenerationIDs_Clone_ObjectNotOwnedByDst) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder dst;
             Program src(ProgramBuilder{});
diff --git a/src/tint/lang/wgsl/ast/color_attribute_test.cc b/src/tint/lang/wgsl/ast/color_attribute_test.cc
index c8dd840..990ee41 100644
--- a/src/tint/lang/wgsl/ast/color_attribute_test.cc
+++ b/src/tint/lang/wgsl/ast/color_attribute_test.cc
@@ -42,7 +42,7 @@
 }
 
 TEST_F(ColorAttributeTest, Assert_Null_Builtin) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Color(nullptr);
@@ -51,7 +51,7 @@
 }
 
 TEST_F(ColorAttributeTest, Assert_DifferentGenerationID_Color) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/compound_assignment_statement_test.cc b/src/tint/lang/wgsl/ast/compound_assignment_statement_test.cc
index 36e302d..ee26804 100644
--- a/src/tint/lang/wgsl/ast/compound_assignment_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/compound_assignment_statement_test.cc
@@ -68,7 +68,7 @@
 }
 
 TEST_F(CompoundAssignmentStatementTest, Assert_Null_LHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<CompoundAssignmentStatement>(nullptr, b.Expr(1_i), core::BinaryOp::kAdd);
@@ -77,7 +77,7 @@
 }
 
 TEST_F(CompoundAssignmentStatementTest, Assert_Null_RHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<CompoundAssignmentStatement>(b.Expr(1_i), nullptr, core::BinaryOp::kAdd);
@@ -86,7 +86,7 @@
 }
 
 TEST_F(CompoundAssignmentStatementTest, Assert_DifferentGenerationID_LHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -97,7 +97,7 @@
 }
 
 TEST_F(CompoundAssignmentStatementTest, Assert_DifferentGenerationID_RHS) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/const_assert_test.cc b/src/tint/lang/wgsl/ast/const_assert_test.cc
index 70e9dfd..0f94a51 100644
--- a/src/tint/lang/wgsl/ast/const_assert_test.cc
+++ b/src/tint/lang/wgsl/ast/const_assert_test.cc
@@ -59,7 +59,7 @@
 }
 
 TEST_F(ConstAssertTest, Assert_Null_Condition) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.ConstAssert(nullptr);
@@ -68,7 +68,7 @@
 }
 
 TEST_F(ConstAssertTest, Assert_DifferentGenerationID_Condition) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/diagnostic_control_test.cc b/src/tint/lang/wgsl/ast/diagnostic_control_test.cc
index 514b9ef..9873fb7 100644
--- a/src/tint/lang/wgsl/ast/diagnostic_control_test.cc
+++ b/src/tint/lang/wgsl/ast/diagnostic_control_test.cc
@@ -37,7 +37,7 @@
 using DiagnosticControlTest = TestHelper;
 
 TEST_F(DiagnosticControlTest, Assert_RuleNotNull) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             DiagnosticControl control(wgsl::DiagnosticSeverity::kWarning, nullptr);
diff --git a/src/tint/lang/wgsl/ast/diagnostic_rule_name_test.cc b/src/tint/lang/wgsl/ast/diagnostic_rule_name_test.cc
index 94ccc9c..387792c 100644
--- a/src/tint/lang/wgsl/ast/diagnostic_rule_name_test.cc
+++ b/src/tint/lang/wgsl/ast/diagnostic_rule_name_test.cc
@@ -41,7 +41,7 @@
 }
 
 TEST_F(DiagnosticRuleNameTest, Assert_NameNotTemplated) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<ast::DiagnosticRuleName>(b.Ident("name", "a", "b", "c"));
@@ -50,7 +50,7 @@
 }
 
 TEST_F(DiagnosticRuleNameTest, Assert_CategoryNotTemplated) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<ast::DiagnosticRuleName>(b.Ident("name"), b.Ident("category", "a", "b", "c"));
diff --git a/src/tint/lang/wgsl/ast/for_loop_statement_test.cc b/src/tint/lang/wgsl/ast/for_loop_statement_test.cc
index a284597..d68ff74 100644
--- a/src/tint/lang/wgsl/ast/for_loop_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/for_loop_statement_test.cc
@@ -73,7 +73,7 @@
 }
 
 TEST_F(ForLoopStatementTest, Assert_Null_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.For(nullptr, nullptr, nullptr, nullptr);
@@ -82,7 +82,7 @@
 }
 
 TEST_F(ForLoopStatementTest, Assert_DifferentGenerationID_Initializer) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -92,7 +92,7 @@
 }
 
 TEST_F(ForLoopStatementTest, Assert_DifferentGenerationID_Condition) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -102,7 +102,7 @@
 }
 
 TEST_F(ForLoopStatementTest, Assert_DifferentGenerationID_Continuing) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -112,7 +112,7 @@
 }
 
 TEST_F(ForLoopStatementTest, Assert_DifferentGenerationID_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/function_test.cc b/src/tint/lang/wgsl/ast/function_test.cc
index 96f7aa5..316d814 100644
--- a/src/tint/lang/wgsl/ast/function_test.cc
+++ b/src/tint/lang/wgsl/ast/function_test.cc
@@ -113,7 +113,7 @@
 }
 
 TEST_F(FunctionTest, Assert_NullName) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Func(static_cast<Identifier*>(nullptr), tint::Empty, b.ty.void_(), tint::Empty);
@@ -122,7 +122,7 @@
 }
 
 TEST_F(FunctionTest, Assert_TemplatedName) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Func(b.Ident("a", "b"), tint::Empty, b.ty.void_(), tint::Empty);
@@ -132,7 +132,7 @@
 
 TEST_F(FunctionTest, Assert_NullParam) {
     using ParamList = tint::Vector<const Parameter*, 2>;
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             ParamList params;
@@ -144,7 +144,7 @@
 }
 
 TEST_F(FunctionTest, Assert_DifferentGenerationID_Symbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -154,7 +154,7 @@
 }
 
 TEST_F(FunctionTest, Assert_DifferentGenerationID_Param) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -168,7 +168,7 @@
 }
 
 TEST_F(FunctionTest, Assert_DifferentGenerationID_Attr) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -181,7 +181,7 @@
 }
 
 TEST_F(FunctionTest, Assert_DifferentGenerationID_ReturnType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -191,7 +191,7 @@
 }
 
 TEST_F(FunctionTest, Assert_DifferentGenerationID_ReturnAttr) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/identifier_expression_test.cc b/src/tint/lang/wgsl/ast/identifier_expression_test.cc
index ebf1916..8d3641e 100644
--- a/src/tint/lang/wgsl/ast/identifier_expression_test.cc
+++ b/src/tint/lang/wgsl/ast/identifier_expression_test.cc
@@ -57,7 +57,7 @@
 }
 
 TEST_F(IdentifierExpressionTest, Assert_InvalidSymbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Expr("");
@@ -66,7 +66,7 @@
 }
 
 TEST_F(IdentifierExpressionTest, Assert_DifferentGenerationID_Symbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/identifier_test.cc b/src/tint/lang/wgsl/ast/identifier_test.cc
index 8f238e7..2a71304 100644
--- a/src/tint/lang/wgsl/ast/identifier_test.cc
+++ b/src/tint/lang/wgsl/ast/identifier_test.cc
@@ -52,7 +52,7 @@
 }
 
 TEST_F(IdentifierTest, Assert_InvalidSymbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Ident("");
@@ -61,7 +61,7 @@
 }
 
 TEST_F(IdentifierTest, Assert_DifferentGenerationID_Symbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/if_statement_test.cc b/src/tint/lang/wgsl/ast/if_statement_test.cc
index 4c738ad..7b37286 100644
--- a/src/tint/lang/wgsl/ast/if_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/if_statement_test.cc
@@ -59,7 +59,7 @@
 }
 
 TEST_F(IfStatementTest, Assert_Null_Condition) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.If(nullptr, b.Block());
@@ -68,7 +68,7 @@
 }
 
 TEST_F(IfStatementTest, Assert_Null_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.If(b.Expr(true), nullptr);
@@ -77,7 +77,7 @@
 }
 
 TEST_F(IfStatementTest, Assert_InvalidElse) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.If(b.Expr(true), b.Block(), b.Else(b.CallStmt(b.Call("foo"))));
@@ -86,7 +86,7 @@
 }
 
 TEST_F(IfStatementTest, Assert_DifferentGenerationID_Cond) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -96,7 +96,7 @@
 }
 
 TEST_F(IfStatementTest, Assert_DifferentGenerationID_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -106,7 +106,7 @@
 }
 
 TEST_F(IfStatementTest, Assert_DifferentGenerationID_ElseStatement) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/increment_decrement_statement_test.cc b/src/tint/lang/wgsl/ast/increment_decrement_statement_test.cc
index 869b522..70730d0 100644
--- a/src/tint/lang/wgsl/ast/increment_decrement_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/increment_decrement_statement_test.cc
@@ -66,7 +66,7 @@
 }
 
 TEST_F(IncrementDecrementStatementTest, Assert_DifferentGenerationID_Expr) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/index_accessor_expression_test.cc b/src/tint/lang/wgsl/ast/index_accessor_expression_test.cc
index ff96f2a..7c1527a 100644
--- a/src/tint/lang/wgsl/ast/index_accessor_expression_test.cc
+++ b/src/tint/lang/wgsl/ast/index_accessor_expression_test.cc
@@ -60,7 +60,7 @@
 }
 
 TEST_F(IndexAccessorExpressionTest, Assert_Null_Array) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.IndexAccessor(nullptr, b.Expr("idx"));
@@ -69,7 +69,7 @@
 }
 
 TEST_F(IndexAccessorExpressionTest, Assert_Null_Index) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.IndexAccessor(b.Expr("arr"), nullptr);
@@ -78,7 +78,7 @@
 }
 
 TEST_F(IndexAccessorExpressionTest, Assert_DifferentGenerationID_Array) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -88,7 +88,7 @@
 }
 
 TEST_F(IndexAccessorExpressionTest, Assert_DifferentGenerationID_Index) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/loop_statement_test.cc b/src/tint/lang/wgsl/ast/loop_statement_test.cc
index fc43b31..3afccfa 100644
--- a/src/tint/lang/wgsl/ast/loop_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/loop_statement_test.cc
@@ -93,7 +93,7 @@
 }
 
 TEST_F(LoopStatementTest, Assert_Null_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<LoopStatement>(nullptr, nullptr, tint::Empty);
@@ -102,7 +102,7 @@
 }
 
 TEST_F(LoopStatementTest, Assert_DifferentGenerationID_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -112,7 +112,7 @@
 }
 
 TEST_F(LoopStatementTest, Assert_DifferentGenerationID_Continuing) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/member_accessor_expression_test.cc b/src/tint/lang/wgsl/ast/member_accessor_expression_test.cc
index 22b1c38..28153d1 100644
--- a/src/tint/lang/wgsl/ast/member_accessor_expression_test.cc
+++ b/src/tint/lang/wgsl/ast/member_accessor_expression_test.cc
@@ -55,7 +55,7 @@
 }
 
 TEST_F(MemberAccessorExpressionTest, Assert_Null_Struct) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<MemberAccessorExpression>(nullptr, b.Ident("member"));
@@ -64,7 +64,7 @@
 }
 
 TEST_F(MemberAccessorExpressionTest, Assert_Null_Member) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<MemberAccessorExpression>(b.Expr("struct"), nullptr);
@@ -73,7 +73,7 @@
 }
 
 TEST_F(MemberAccessorExpressionTest, Assert_DifferentGenerationID_Struct) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -83,7 +83,7 @@
 }
 
 TEST_F(MemberAccessorExpressionTest, Assert_DifferentGenerationID_Member) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -93,7 +93,7 @@
 }
 
 TEST_F(MemberAccessorExpressionTest, Assert_MemberNotTemplated) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<MemberAccessorExpression>(b.Expr("structure"),
diff --git a/src/tint/lang/wgsl/ast/module_test.cc b/src/tint/lang/wgsl/ast/module_test.cc
index e5949a6..36773ed 100644
--- a/src/tint/lang/wgsl/ast/module_test.cc
+++ b/src/tint/lang/wgsl/ast/module_test.cc
@@ -52,7 +52,7 @@
 }
 
 TEST_F(ModuleTest, Assert_Null_GlobalVariable) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder builder;
             builder.AST().AddGlobalVariable(nullptr);
@@ -61,7 +61,7 @@
 }
 
 TEST_F(ModuleTest, Assert_Null_TypeDecl) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder builder;
             builder.AST().AddTypeDecl(nullptr);
@@ -70,7 +70,7 @@
 }
 
 TEST_F(ModuleTest, Assert_DifferentGenerationID_Function) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -81,7 +81,7 @@
 }
 
 TEST_F(ModuleTest, Assert_DifferentGenerationID_GlobalVariable) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -91,7 +91,7 @@
 }
 
 TEST_F(ModuleTest, Assert_Null_Function) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder builder;
             builder.AST().AddFunction(nullptr);
diff --git a/src/tint/lang/wgsl/ast/return_statement_test.cc b/src/tint/lang/wgsl/ast/return_statement_test.cc
index ed7c9e0..138a46c 100644
--- a/src/tint/lang/wgsl/ast/return_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/return_statement_test.cc
@@ -65,7 +65,7 @@
 }
 
 TEST_F(ReturnStatementTest, Assert_DifferentGenerationID_Expr) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/struct_member_test.cc b/src/tint/lang/wgsl/ast/struct_member_test.cc
index c1e429b..8a76d9f 100644
--- a/src/tint/lang/wgsl/ast/struct_member_test.cc
+++ b/src/tint/lang/wgsl/ast/struct_member_test.cc
@@ -58,7 +58,7 @@
 }
 
 TEST_F(StructMemberTest, Assert_Null_Name) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Member(static_cast<Identifier*>(nullptr), b.ty.i32());
@@ -67,7 +67,7 @@
 }
 
 TEST_F(StructMemberTest, Assert_Null_Type) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Member("a", Type{});
@@ -76,7 +76,7 @@
 }
 
 TEST_F(StructMemberTest, Assert_Null_Attribute) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Member("a", b.ty.i32(), tint::Vector{b.MemberSize(4_a), nullptr});
@@ -85,7 +85,7 @@
 }
 
 TEST_F(StructMemberTest, Assert_DifferentGenerationID_Symbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -95,7 +95,7 @@
 }
 
 TEST_F(StructMemberTest, Assert_DifferentGenerationID_Attribute) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/struct_test.cc b/src/tint/lang/wgsl/ast/struct_test.cc
index d0e78de..b47e920 100644
--- a/src/tint/lang/wgsl/ast/struct_test.cc
+++ b/src/tint/lang/wgsl/ast/struct_test.cc
@@ -81,7 +81,7 @@
 }
 
 TEST_F(AstStructTest, Assert_Null_StructMember) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Structure(b.Sym("S"), tint::Vector{b.Member("a", b.ty.i32()), nullptr}, tint::Empty);
@@ -90,7 +90,7 @@
 }
 
 TEST_F(AstStructTest, Assert_Null_Attribute) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Structure(b.Sym("S"), tint::Vector{b.Member("a", b.ty.i32())},
@@ -100,7 +100,7 @@
 }
 
 TEST_F(AstStructTest, Assert_DifferentGenerationID_StructMember) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -110,7 +110,7 @@
 }
 
 TEST_F(AstStructTest, Assert_DifferentGenerationID_Attribute) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/switch_statement_test.cc b/src/tint/lang/wgsl/ast/switch_statement_test.cc
index fa1fc40..2105a6c 100644
--- a/src/tint/lang/wgsl/ast/switch_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/switch_statement_test.cc
@@ -88,7 +88,7 @@
 
 TEST_F(SwitchStatementTest, Assert_Null_Condition) {
     using CaseStatementList = tint::Vector<const CaseStatement*, 2>;
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             CaseStatementList cases;
@@ -101,7 +101,7 @@
 
 TEST_F(SwitchStatementTest, Assert_Null_CaseStatement) {
     using CaseStatementList = tint::Vector<const CaseStatement*, 2>;
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<SwitchStatement>(b.Expr(true), CaseStatementList{nullptr}, tint::Empty,
@@ -111,7 +111,7 @@
 }
 
 TEST_F(SwitchStatementTest, Assert_DifferentGenerationID_Condition) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -129,7 +129,7 @@
 }
 
 TEST_F(SwitchStatementTest, Assert_DifferentGenerationID_CaseStatement) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/templated_identifier_test.cc b/src/tint/lang/wgsl/ast/templated_identifier_test.cc
index ded0e81..a47a905 100644
--- a/src/tint/lang/wgsl/ast/templated_identifier_test.cc
+++ b/src/tint/lang/wgsl/ast/templated_identifier_test.cc
@@ -63,7 +63,7 @@
 }
 
 TEST_F(TemplatedIdentifierTest, Assert_InvalidSymbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Expr("");
@@ -72,7 +72,7 @@
 }
 
 TEST_F(TemplatedIdentifierTest, Assert_DifferentGenerationID_Symbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -82,7 +82,7 @@
 }
 
 TEST_F(TemplatedIdentifierTest, Assert_DifferentGenerationID_TemplateArg) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/transform/add_block_attribute_fuzz.cc b/src/tint/lang/wgsl/ast/transform/add_block_attribute_fuzz.cc
index 7898243..b9a5389 100644
--- a/src/tint/lang/wgsl/ast/transform/add_block_attribute_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/add_block_attribute_fuzz.cc
@@ -35,7 +35,9 @@
     DataMap outputs;
     if (auto result = AddBlockAttribute{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "AddBlockAttribute returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "AddBlockAttribute returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/add_empty_entry_point_fuzz.cc b/src/tint/lang/wgsl/ast/transform/add_empty_entry_point_fuzz.cc
index cee5702..ff81441 100644
--- a/src/tint/lang/wgsl/ast/transform/add_empty_entry_point_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/add_empty_entry_point_fuzz.cc
@@ -35,7 +35,9 @@
     DataMap outputs;
     if (auto result = AddEmptyEntryPoint{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "AddEmptyEntryPoint returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "AddEmptyEntryPoint returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/array_length_from_uniform_fuzz.cc b/src/tint/lang/wgsl/ast/transform/array_length_from_uniform_fuzz.cc
index 525f57e..7b1f693 100644
--- a/src/tint/lang/wgsl/ast/transform/array_length_from_uniform_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/array_length_from_uniform_fuzz.cc
@@ -72,6 +72,7 @@
     if (auto result = ArrayLengthFromUniform{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "ArrayLengthFromUniform returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/binding_remapper_fuzz.cc b/src/tint/lang/wgsl/ast/transform/binding_remapper_fuzz.cc
index 2f27549..071e83d 100644
--- a/src/tint/lang/wgsl/ast/transform/binding_remapper_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/binding_remapper_fuzz.cc
@@ -78,7 +78,9 @@
     DataMap outputs;
     if (auto result = BindingRemapper{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "BindingRemapper returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "BindingRemapper returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/builtin_polyfill_fuzz.cc b/src/tint/lang/wgsl/ast/transform/builtin_polyfill_fuzz.cc
index adce548..8694e3b 100644
--- a/src/tint/lang/wgsl/ast/transform/builtin_polyfill_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/builtin_polyfill_fuzz.cc
@@ -38,7 +38,9 @@
     DataMap outputs;
     if (auto result = BuiltinPolyfill{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "BuiltinPolyfill returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "BuiltinPolyfill returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/canonicalize_entry_point_io_fuzz.cc b/src/tint/lang/wgsl/ast/transform/canonicalize_entry_point_io_fuzz.cc
index 136291a..671a35b 100644
--- a/src/tint/lang/wgsl/ast/transform/canonicalize_entry_point_io_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/canonicalize_entry_point_io_fuzz.cc
@@ -40,6 +40,7 @@
     if (auto result = CanonicalizeEntryPointIO{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "CanonicalizeEntryPointIO returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/clamp_frag_depth_fuzz.cc b/src/tint/lang/wgsl/ast/transform/clamp_frag_depth_fuzz.cc
index 2854240..6de6553 100644
--- a/src/tint/lang/wgsl/ast/transform/clamp_frag_depth_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/clamp_frag_depth_fuzz.cc
@@ -27,6 +27,7 @@
 
 #include "src/tint/cmd/fuzz/wgsl/fuzz.h"
 #include "src/tint/lang/wgsl/ast/transform/clamp_frag_depth.h"
+#include "src/tint/lang/wgsl/program/program.h"
 
 namespace tint::ast::transform {
 namespace {
@@ -49,7 +50,9 @@
     DataMap outputs;
     if (auto result = ClampFragDepth{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "ClampFragDepth returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "ClampFragDepth returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/demote_to_helper_fuzz.cc b/src/tint/lang/wgsl/ast/transform/demote_to_helper_fuzz.cc
index 180ab67..16e3833 100644
--- a/src/tint/lang/wgsl/ast/transform/demote_to_helper_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/demote_to_helper_fuzz.cc
@@ -35,7 +35,9 @@
     DataMap outputs;
     if (auto result = DemoteToHelper{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "DemoteToHelper returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "DemoteToHelper returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/direct_variable_access_fuzz.cc b/src/tint/lang/wgsl/ast/transform/direct_variable_access_fuzz.cc
index 5123798..e1d1893 100644
--- a/src/tint/lang/wgsl/ast/transform/direct_variable_access_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/direct_variable_access_fuzz.cc
@@ -40,6 +40,7 @@
     if (auto result = DirectVariableAccess{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "DirectVariableAccess returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/disable_uniformity_analysis_fuzz.cc b/src/tint/lang/wgsl/ast/transform/disable_uniformity_analysis_fuzz.cc
index 378eb5f..1f5e153 100644
--- a/src/tint/lang/wgsl/ast/transform/disable_uniformity_analysis_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/disable_uniformity_analysis_fuzz.cc
@@ -36,6 +36,7 @@
     if (auto result = DisableUniformityAnalysis{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "DisableUniformityAnalysis returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/expand_compound_assignment_fuzz.cc b/src/tint/lang/wgsl/ast/transform/expand_compound_assignment_fuzz.cc
index d017dbb..7776590 100644
--- a/src/tint/lang/wgsl/ast/transform/expand_compound_assignment_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/expand_compound_assignment_fuzz.cc
@@ -36,6 +36,7 @@
     if (auto result = ExpandCompoundAssignment{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "ExpandCompoundAssignment returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/first_index_offset_fuzz.cc b/src/tint/lang/wgsl/ast/transform/first_index_offset_fuzz.cc
index b7ed12e..2f3a607 100644
--- a/src/tint/lang/wgsl/ast/transform/first_index_offset_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/first_index_offset_fuzz.cc
@@ -59,7 +59,9 @@
     DataMap outputs;
     if (auto result = FirstIndexOffset{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "FirstIndexOffset returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "FirstIndexOffset returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/fold_constants_fuzz.cc b/src/tint/lang/wgsl/ast/transform/fold_constants_fuzz.cc
index 4c48a90..668f7a5 100644
--- a/src/tint/lang/wgsl/ast/transform/fold_constants_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/fold_constants_fuzz.cc
@@ -35,7 +35,9 @@
     DataMap outputs;
     if (auto result = FoldConstants{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "FoldConstants returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "FoldConstants returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/multiplanar_external_texture_fuzz.cc b/src/tint/lang/wgsl/ast/transform/multiplanar_external_texture_fuzz.cc
index caddb35..4c6efeb 100644
--- a/src/tint/lang/wgsl/ast/transform/multiplanar_external_texture_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/multiplanar_external_texture_fuzz.cc
@@ -95,6 +95,7 @@
     if (auto result = MultiplanarExternalTexture{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "MultiplanarExternalTexture returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/offset_first_index_fuzz.cc b/src/tint/lang/wgsl/ast/transform/offset_first_index_fuzz.cc
index 04c13a2..f4c9faf 100644
--- a/src/tint/lang/wgsl/ast/transform/offset_first_index_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/offset_first_index_fuzz.cc
@@ -55,7 +55,9 @@
     DataMap outputs;
     if (auto result = OffsetFirstIndex{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "OffsetFirstIndex returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "OffsetFirstIndex returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/preserve_padding_fuzz.cc b/src/tint/lang/wgsl/ast/transform/preserve_padding_fuzz.cc
index 486a65a..963da24 100644
--- a/src/tint/lang/wgsl/ast/transform/preserve_padding_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/preserve_padding_fuzz.cc
@@ -35,7 +35,9 @@
     DataMap outputs;
     if (auto result = PreservePadding{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "PreservePadding returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "PreservePadding returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/promote_initializers_to_let_fuzz.cc b/src/tint/lang/wgsl/ast/transform/promote_initializers_to_let_fuzz.cc
index bf928e6..ed3518f 100644
--- a/src/tint/lang/wgsl/ast/transform/promote_initializers_to_let_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/promote_initializers_to_let_fuzz.cc
@@ -36,6 +36,7 @@
     if (auto result = PromoteInitializersToLet{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "PromoteInitializersToLet returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/promote_side_effects_to_decl_fuzz.cc b/src/tint/lang/wgsl/ast/transform/promote_side_effects_to_decl_fuzz.cc
index 57a15f8..423d3ed 100644
--- a/src/tint/lang/wgsl/ast/transform/promote_side_effects_to_decl_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/promote_side_effects_to_decl_fuzz.cc
@@ -54,6 +54,7 @@
     if (auto result = PromoteSideEffectsToDecl{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "PromoteSideEffectsToDecl returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/remove_continue_in_switch_fuzz.cc b/src/tint/lang/wgsl/ast/transform/remove_continue_in_switch_fuzz.cc
index 049b57c..b49f1ed 100644
--- a/src/tint/lang/wgsl/ast/transform/remove_continue_in_switch_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/remove_continue_in_switch_fuzz.cc
@@ -36,6 +36,7 @@
     if (auto result = RemoveContinueInSwitch{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "RemoveContinueInSwitch returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/remove_phonies_fuzz.cc b/src/tint/lang/wgsl/ast/transform/remove_phonies_fuzz.cc
index 92faf38..823fc6f 100644
--- a/src/tint/lang/wgsl/ast/transform/remove_phonies_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/remove_phonies_fuzz.cc
@@ -35,7 +35,9 @@
     DataMap outputs;
     if (auto result = RemovePhonies{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "RemovePhonies returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "RemovePhonies returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/remove_unreachable_statements_fuzz.cc b/src/tint/lang/wgsl/ast/transform/remove_unreachable_statements_fuzz.cc
index c61d8aa..c803823 100644
--- a/src/tint/lang/wgsl/ast/transform/remove_unreachable_statements_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/remove_unreachable_statements_fuzz.cc
@@ -52,6 +52,7 @@
     if (auto result = RemoveUnreachableStatements{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "RemoveUnreachableStatements returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/single_entry_point_fuzz.cc b/src/tint/lang/wgsl/ast/transform/single_entry_point_fuzz.cc
index 5cec2bb..5df7964 100644
--- a/src/tint/lang/wgsl/ast/transform/single_entry_point_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/single_entry_point_fuzz.cc
@@ -56,7 +56,9 @@
     DataMap outputs;
     if (auto result = SingleEntryPoint{}.Apply(program, inputs, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "SingleEntryPoint returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "SingleEntryPoint returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/std140_fuzz.cc b/src/tint/lang/wgsl/ast/transform/std140_fuzz.cc
index aedf74c..3606f96 100644
--- a/src/tint/lang/wgsl/ast/transform/std140_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/std140_fuzz.cc
@@ -57,7 +57,9 @@
     DataMap outputs;
     if (auto result = Std140{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "Std140 returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "Std140 returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/unshadow_fuzz.cc b/src/tint/lang/wgsl/ast/transform/unshadow_fuzz.cc
index 064ffaf..752a501 100644
--- a/src/tint/lang/wgsl/ast/transform/unshadow_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/unshadow_fuzz.cc
@@ -35,7 +35,9 @@
     DataMap outputs;
     if (auto result = Unshadow{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
-            TINT_ICE() << "Unshadow returned invalid program:\n" << result->Diagnostics();
+            TINT_ICE() << "Unshadow returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
+                       << result->Diagnostics();
         }
     }
 }
diff --git a/src/tint/lang/wgsl/ast/transform/vectorize_scalar_matrix_initializers_fuzz.cc b/src/tint/lang/wgsl/ast/transform/vectorize_scalar_matrix_initializers_fuzz.cc
index 407538c..f1c6e2d 100644
--- a/src/tint/lang/wgsl/ast/transform/vectorize_scalar_matrix_initializers_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/vectorize_scalar_matrix_initializers_fuzz.cc
@@ -36,6 +36,7 @@
     if (auto result = VectorizeScalarMatrixInitializers{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "VectorizeScalarMatrixInitializers returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/transform/zero_init_workgroup_memory_fuzz.cc b/src/tint/lang/wgsl/ast/transform/zero_init_workgroup_memory_fuzz.cc
index 2e3650d..b9c569d 100644
--- a/src/tint/lang/wgsl/ast/transform/zero_init_workgroup_memory_fuzz.cc
+++ b/src/tint/lang/wgsl/ast/transform/zero_init_workgroup_memory_fuzz.cc
@@ -41,6 +41,7 @@
     if (auto result = ZeroInitWorkgroupMemory{}.Apply(program, DataMap{}, outputs)) {
         if (!result->IsValid()) {
             TINT_ICE() << "ZeroInitWorkgroupMemory returned invalid program:\n"
+                       << Program::printer(*result) << "\n"
                        << result->Diagnostics();
         }
     }
diff --git a/src/tint/lang/wgsl/ast/unary_op_expression_test.cc b/src/tint/lang/wgsl/ast/unary_op_expression_test.cc
index 3ce46c9..45b2c52 100644
--- a/src/tint/lang/wgsl/ast/unary_op_expression_test.cc
+++ b/src/tint/lang/wgsl/ast/unary_op_expression_test.cc
@@ -58,7 +58,7 @@
 }
 
 TEST_F(UnaryOpExpressionTest, Assert_Null_Expression) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<UnaryOpExpression>(core::UnaryOp::kNot, nullptr);
@@ -67,7 +67,7 @@
 }
 
 TEST_F(UnaryOpExpressionTest, Assert_DifferentGenerationID_Expression) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/variable_decl_statement_test.cc b/src/tint/lang/wgsl/ast/variable_decl_statement_test.cc
index 1e3588d..3b4640c 100644
--- a/src/tint/lang/wgsl/ast/variable_decl_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/variable_decl_statement_test.cc
@@ -58,7 +58,7 @@
 }
 
 TEST_F(VariableDeclStatementTest, Assert_Null_Variable) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.create<VariableDeclStatement>(nullptr);
@@ -67,7 +67,7 @@
 }
 
 TEST_F(VariableDeclStatementTest, Assert_DifferentGenerationID_Variable) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/variable_test.cc b/src/tint/lang/wgsl/ast/variable_test.cc
index c85ec93..c24ab00 100644
--- a/src/tint/lang/wgsl/ast/variable_test.cc
+++ b/src/tint/lang/wgsl/ast/variable_test.cc
@@ -76,7 +76,7 @@
 }
 
 TEST_F(VariableTest, Assert_Null_Name) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Var(static_cast<Identifier*>(nullptr), b.ty.i32());
@@ -85,7 +85,7 @@
 }
 
 TEST_F(VariableTest, Assert_DifferentGenerationID_Symbol) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -95,7 +95,7 @@
 }
 
 TEST_F(VariableTest, Assert_DifferentGenerationID_Initializer) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/ast/while_statement_test.cc b/src/tint/lang/wgsl/ast/while_statement_test.cc
index e678d12..be9e07e 100644
--- a/src/tint/lang/wgsl/ast/while_statement_test.cc
+++ b/src/tint/lang/wgsl/ast/while_statement_test.cc
@@ -65,7 +65,7 @@
 }
 
 TEST_F(WhileStatementTest, Assert_Null_Cond) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             auto* body = b.Block();
@@ -75,7 +75,7 @@
 }
 
 TEST_F(WhileStatementTest, Assert_Null_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             auto* cond =
@@ -86,7 +86,7 @@
 }
 
 TEST_F(WhileStatementTest, Assert_DifferentGenerationID_Condition) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
@@ -96,7 +96,7 @@
 }
 
 TEST_F(WhileStatementTest, Assert_DifferentGenerationID_Body) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b1;
             ProgramBuilder b2;
diff --git a/src/tint/lang/wgsl/program/clone_context_test.cc b/src/tint/lang/wgsl/program/clone_context_test.cc
index 936cb6b..527a6a2 100644
--- a/src/tint/lang/wgsl/program/clone_context_test.cc
+++ b/src/tint/lang/wgsl/program/clone_context_test.cc
@@ -1051,7 +1051,7 @@
 TEST_F(ProgramCloneContextNodeTest, CloneWithReplaceAll_SameTypeTwice) {
     std::string node_name = TypeInfo::Of<Node>().name;
 
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1068,7 +1068,7 @@
     std::string node_name = TypeInfo::Of<Node>().name;
     std::string replaceable_name = TypeInfo::Of<Replaceable>().name;
 
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1085,7 +1085,7 @@
     std::string node_name = TypeInfo::Of<Node>().name;
     std::string replaceable_name = TypeInfo::Of<Replaceable>().name;
 
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1101,7 +1101,7 @@
 using ProgramCloneContextTest = ::testing::Test;
 
 TEST_F(ProgramCloneContextTest, CloneWithReplaceAll_SymbolsTwice) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder cloned;
             Program original;
@@ -1207,7 +1207,7 @@
 }
 
 TEST_F(ProgramCloneContextTest, GenerationIDs_Clone_ObjectNotOwnedBySrc) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder dst;
             Program src(ProgramBuilder{});
@@ -1220,7 +1220,7 @@
 }
 
 TEST_F(ProgramCloneContextTest, GenerationIDs_Clone_ObjectNotOwnedByDst) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder dst;
             Program src(ProgramBuilder{});
diff --git a/src/tint/lang/wgsl/program/program_test.cc b/src/tint/lang/wgsl/program/program_test.cc
index dff5b23..4c91d2d 100644
--- a/src/tint/lang/wgsl/program/program_test.cc
+++ b/src/tint/lang/wgsl/program/program_test.cc
@@ -65,7 +65,7 @@
 }
 
 TEST_F(ProgramTest, Assert_NullGlobalVariable) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.AST().AddGlobalVariable(nullptr);
@@ -74,7 +74,7 @@
 }
 
 TEST_F(ProgramTest, Assert_NullTypeDecl) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.AST().AddTypeDecl(nullptr);
@@ -83,7 +83,7 @@
 }
 
 TEST_F(ProgramTest, Assert_Null_Function) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.AST().AddFunction(nullptr);
diff --git a/src/tint/lang/wgsl/reader/lower/lower.cc b/src/tint/lang/wgsl/reader/lower/lower.cc
index 0752652..33f1999 100644
--- a/src/tint/lang/wgsl/reader/lower/lower.cc
+++ b/src/tint/lang/wgsl/reader/lower/lower.cc
@@ -198,19 +198,16 @@
                     //    call workgroupBarrier
                     b.InsertBefore(call, [&] {
                         b.Call(ty.void_(), core::BuiltinFn::kWorkgroupBarrier);
-                        auto* load = b.Load(call->Args()[0]);
-                        call->Result(0)->ReplaceAllUsesWith(load->Result(0));
+                        b.LoadWithResult(call->DetachResult(), call->Args()[0]);
                         b.Call(ty.void_(), core::BuiltinFn::kWorkgroupBarrier);
                     });
                     break;
                 }
                 default: {
                     Vector<core::ir::Value*, 8> args(call->Args());
-                    auto* replacement =
-                        mod.allocators.instructions.Create<core::ir::CoreBuiltinCall>(
-                            call->Result(0), Convert(call->Func()), std::move(args));
+                    auto* replacement = b.CallWithResult(call->DetachResult(),
+                                                         Convert(call->Func()), std::move(args));
                     call->ReplaceWith(replacement);
-                    call->ClearResults();
                     break;
                 }
             }
diff --git a/src/tint/lang/wgsl/resolver/resolver_test.cc b/src/tint/lang/wgsl/resolver/resolver_test.cc
index 152cbbe..c73a75b 100644
--- a/src/tint/lang/wgsl/resolver/resolver_test.cc
+++ b/src/tint/lang/wgsl/resolver/resolver_test.cc
@@ -2087,7 +2087,7 @@
 }
 
 TEST_F(ResolverTest, ASTNodeNotReached) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.Ident("ident");
@@ -2098,7 +2098,7 @@
 }
 
 TEST_F(ResolverTest, ASTNodeReachedTwice) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             auto* expr = b.Expr(1_i);
diff --git a/src/tint/lang/wgsl/resolver/validation_test.cc b/src/tint/lang/wgsl/resolver/validation_test.cc
index 5d0f51c..9c306f7 100644
--- a/src/tint/lang/wgsl/resolver/validation_test.cc
+++ b/src/tint/lang/wgsl/resolver/validation_test.cc
@@ -133,7 +133,7 @@
 }
 
 TEST_F(ResolverValidationTest, UnhandledStmt) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.WrapInFunction(b.create<FakeStmt>());
@@ -164,7 +164,7 @@
 }
 
 TEST_F(ResolverValidationTest, Expr_ErrUnknownExprType) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             ProgramBuilder b;
             b.WrapInFunction(b.create<FakeExpr>());
diff --git a/src/tint/lang/wgsl/writer/raise/raise.cc b/src/tint/lang/wgsl/writer/raise/raise.cc
index c576ac4..d06cb72 100644
--- a/src/tint/lang/wgsl/writer/raise/raise.cc
+++ b/src/tint/lang/wgsl/writer/raise/raise.cc
@@ -30,6 +30,7 @@
 #include <utility>
 
 #include "src/tint/lang/core/builtin_fn.h"
+#include "src/tint/lang/core/ir/builder.h"
 #include "src/tint/lang/core/ir/core_builtin_call.h"
 #include "src/tint/lang/core/ir/load.h"
 #include "src/tint/lang/core/type/pointer.h"
@@ -175,16 +176,15 @@
     TINT_ICE() << "unhandled builtin function: " << fn;
 }
 
-void ReplaceBuiltinFnCall(core::ir::Module& mod, core::ir::CoreBuiltinCall* call) {
+void ReplaceBuiltinFnCall(core::ir::Builder& b, core::ir::CoreBuiltinCall* call) {
     Vector<core::ir::Value*, 8> args(call->Args());
-    auto* replacement = mod.allocators.instructions.Create<wgsl::ir::BuiltinCall>(
-        call->Result(0), Convert(call->Func()), std::move(args));
+    auto* replacement = b.CallWithResult<wgsl::ir::BuiltinCall>(
+        call->DetachResult(), Convert(call->Func()), std::move(args));
     call->ReplaceWith(replacement);
-    call->ClearResults();
     call->Destroy();
 }
 
-void ReplaceWorkgroupBarrier(core::ir::Module& mod, core::ir::CoreBuiltinCall* call) {
+void ReplaceWorkgroupBarrier(core::ir::Builder& b, core::ir::CoreBuiltinCall* call) {
     // Pattern match:
     //    call workgroupBarrier
     //    %value = load &ptr
@@ -196,14 +196,14 @@
     if (!load || load->From()->Type()->As<core::type::Pointer>()->AddressSpace() !=
                      core::AddressSpace::kWorkgroup) {
         // No match
-        ReplaceBuiltinFnCall(mod, call);
+        ReplaceBuiltinFnCall(b, call);
         return;
     }
 
     auto* post_load = As<core::ir::CoreBuiltinCall>(load->next.Get());
     if (!post_load || post_load->Func() != core::BuiltinFn::kWorkgroupBarrier) {
         // No match
-        ReplaceBuiltinFnCall(mod, call);
+        ReplaceBuiltinFnCall(b, call);
         return;
     }
 
@@ -212,24 +212,24 @@
     call->Destroy();
 
     // Replace load with workgroupUniformLoad
-    auto* replacement = mod.allocators.instructions.Create<wgsl::ir::BuiltinCall>(
-        load->Result(0), wgsl::BuiltinFn::kWorkgroupUniformLoad, Vector{load->From()});
+    auto* replacement = b.CallWithResult<wgsl::ir::BuiltinCall>(
+        load->DetachResult(), wgsl::BuiltinFn::kWorkgroupUniformLoad, Vector{load->From()});
     load->ReplaceWith(replacement);
-    load->ClearResults();
     load->Destroy();
 }
 
 }  // namespace
 
 Result<SuccessType> Raise(core::ir::Module& mod) {
+    core::ir::Builder b{mod};
     for (auto* inst : mod.Instructions()) {
         if (auto* call = inst->As<core::ir::CoreBuiltinCall>()) {
             switch (call->Func()) {
                 case core::BuiltinFn::kWorkgroupBarrier:
-                    ReplaceWorkgroupBarrier(mod, call);
+                    ReplaceWorkgroupBarrier(b, call);
                     break;
                 default:
-                    ReplaceBuiltinFnCall(mod, call);
+                    ReplaceBuiltinFnCall(b, call);
                     break;
             }
         }
diff --git a/src/tint/lang/wgsl/writer/writer.cc b/src/tint/lang/wgsl/writer/writer.cc
index 92819c0..4013848 100644
--- a/src/tint/lang/wgsl/writer/writer.cc
+++ b/src/tint/lang/wgsl/writer/writer.cc
@@ -68,6 +68,14 @@
 }
 
 Result<Output> WgslFromIR(core::ir::Module& module, const ProgramOptions& options) {
+    auto res = ProgramFromIR(module, options);
+    if (res != Success) {
+        return res.Failure();
+    }
+    return Generate(res.Move(), Options{});
+}
+
+Result<Program> ProgramFromIR(core::ir::Module& module, const ProgramOptions& options) {
     // core-dialect -> WGSL-dialect
     if (auto res = Raise(module); res != Success) {
         return res.Failure();
@@ -78,7 +86,7 @@
         return Failure{program.Diagnostics()};
     }
 
-    return Generate(program, Options{});
+    return program;
 }
 
 }  // namespace tint::wgsl::writer
diff --git a/src/tint/lang/wgsl/writer/writer.h b/src/tint/lang/wgsl/writer/writer.h
index 604ace2..a79ea4d 100644
--- a/src/tint/lang/wgsl/writer/writer.h
+++ b/src/tint/lang/wgsl/writer/writer.h
@@ -56,6 +56,11 @@
 /// @returns the resulting WGSL, or failure
 Result<Output> WgslFromIR(core::ir::Module& module, const ProgramOptions& options);
 
+/// Generate a Program from a core-dialect ir::Module.
+/// @param module the core-dialect ir::Module.
+/// @returns the resulting Program, or failure
+Result<Program> ProgramFromIR(core::ir::Module& module, const ProgramOptions& options);
+
 }  // namespace tint::wgsl::writer
 
 #endif  // SRC_TINT_LANG_WGSL_WRITER_WRITER_H_
diff --git a/src/tint/utils/containers/vector_test.cc b/src/tint/utils/containers/vector_test.cc
index f437ddb..c509cbb 100644
--- a/src/tint/utils/containers/vector_test.cc
+++ b/src/tint/utils/containers/vector_test.cc
@@ -2110,7 +2110,7 @@
 }
 
 TEST(TintVectorTest, AssertOOBs) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Vector vec{1};
             [[maybe_unused]] int i = vec[1];
@@ -2121,7 +2121,7 @@
 #if TINT_VECTOR_MUTATION_CHECKS_ENABLED
 TEST(TintVectorTest, AssertPushWhileIterating) {
     using V = Vector<int, 4>;
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             V vec;
             vec.Push(1);
@@ -2136,7 +2136,7 @@
 
 TEST(TintVectorTest, AssertPopWhileIterating) {
     using V = Vector<int, 4>;
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             V vec;
             vec.Push(1);
@@ -2151,7 +2151,7 @@
 
 TEST(TintVectorTest, AssertClearWhileIterating) {
     using V = Vector<int, 4>;
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             V vec;
             vec.Push(1);
@@ -2446,7 +2446,7 @@
 }
 
 TEST(TintVectorRefTest, AssertOOBs) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Vector vec{1};
             const VectorRef<int> vec_ref(vec);
diff --git a/src/tint/utils/diagnostic/diagnostic.h b/src/tint/utils/diagnostic/diagnostic.h
index 6471b5a..4b627df 100644
--- a/src/tint/utils/diagnostic/diagnostic.h
+++ b/src/tint/utils/diagnostic/diagnostic.h
@@ -185,6 +185,11 @@
         return Add(std::move(error));
     }
 
+    /// Ensures that the diagnostic list can fit an additional @p count diagnostics without
+    /// resizing. This is useful for ensuring that a reference returned by the AddX() methods is not
+    /// invalidated after another Add().
+    void ReserveAdditional(size_t count) { entries_.Reserve(entries_.Length() + count); }
+
     /// @returns true iff the diagnostic list contains errors diagnostics (or of
     /// higher severity).
     bool ContainsErrors() const { return error_count_ > 0; }
diff --git a/src/tint/utils/ice/ice_test.cc b/src/tint/utils/ice/ice_test.cc
index 0afa5de..d0a992c 100644
--- a/src/tint/utils/ice/ice_test.cc
+++ b/src/tint/utils/ice/ice_test.cc
@@ -33,7 +33,7 @@
 namespace {
 
 TEST(ICETest_AssertTrue_Test, Unreachable) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             if ((true)) {
                 TINT_UNREACHABLE();
@@ -47,7 +47,7 @@
 }
 
 TEST(ICETest_AssertTrue_Test, AssertFalse) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             if ((true)) {
                 TINT_ASSERT(false);
diff --git a/src/tint/utils/rtti/switch_test.cc b/src/tint/utils/rtti/switch_test.cc
index 4ae0f95..e4241ff 100644
--- a/src/tint/utils/rtti/switch_test.cc
+++ b/src/tint/utils/rtti/switch_test.cc
@@ -230,7 +230,7 @@
 }
 
 TEST(Castable, SwitchMustMatch_NoMatchWithoutReturnValue) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             std::unique_ptr<Animal> frog = std::make_unique<Frog>();
             Switch(
@@ -243,7 +243,7 @@
 }
 
 TEST(Castable, SwitchMustMatch_NoMatchWithReturnValue) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             std::unique_ptr<Animal> frog = std::make_unique<Frog>();
             int res = Switch(
@@ -257,7 +257,7 @@
 }
 
 TEST(Castable, SwitchMustMatch_NullptrWithoutReturnValue) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             Switch(
                 static_cast<CastableBase*>(nullptr),  //
@@ -269,7 +269,7 @@
 }
 
 TEST(Castable, SwitchMustMatch_NullptrWithReturnValue) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             int res = Switch(
                 static_cast<CastableBase*>(nullptr),  //
diff --git a/src/tint/utils/symbol/symbol_table_test.cc b/src/tint/utils/symbol/symbol_table_test.cc
index 8421e1f..9328640 100644
--- a/src/tint/utils/symbol/symbol_table_test.cc
+++ b/src/tint/utils/symbol/symbol_table_test.cc
@@ -50,7 +50,7 @@
 }
 
 TEST_F(SymbolTableTest, AssertsForBlankString) {
-    EXPECT_DEATH(
+    EXPECT_DEATH_IF_SUPPORTED(
         {
             auto generation_id = GenerationID::New();
             SymbolTable s{generation_id};