diff --git a/docs/general/02-Values.md b/docs/general/02-Values.md
index c208cd27..9be72233 100644
--- a/docs/general/02-Values.md
+++ b/docs/general/02-Values.md
@@ -62,3 +62,32 @@ Console.WriteLine($"Computed font-size: {computedFontSize}");
- Shorthand values (e.g., `margin`, `background`) are decomposed internally to longhands.
- Variables (`var(--x)`) may defer full resolution until cascade context is available.
- Comparing raw source strings is often misleading; compare parsed or computed values instead.
+
+## Custom Properties At Computed-Value Time
+
+Custom properties are resolved only during style computation, for each element before
+they are inherited. `GetDeclarations`, `ComputeExplicitStyle`, `ComputeCascadedStyle`,
+and render-tree `SpecifiedStyle` retain the original variable expressions. Computed
+results are separate declarations and do not rewrite stylesheet or inline values.
+
+During computation, an inherited
+alias keeps the parent's resolved value; changing its dependencies on a child does not
+resolve that alias again. A declaration explicitly matching both elements is resolved
+locally on each element.
+
+Following [CSS Variables dependency-cycle rules](https://drafts.csswg.org/css-variables-1/#cycles),
+every property in a cycle becomes guaranteed-invalid, including cycles through unused
+fallbacks. A consuming `var(--name, fallback)` can recover from an invalid or missing
+custom property. Without a usable fallback, the consuming declaration uses its inherited
+or initial value, not an earlier declaration from the cascade. A valid custom-property
+value that does not match the consumer's grammar does not trigger the `var()` fallback.
+
+Dependency analysis and fallback substitution are iterative, including deeply nested
+fallbacks. The public parser still represents nested `var()` fallbacks as `CssVarValue`
+objects, and direct `CssReferenceValue.Compute` calls honor the supplied `References`
+array, including subsequent changes to its entries.
+
+Expanded values during style computation are limited to 1,048,576 UTF-16 code units (including token
+separators) to bound exponential substitution; an expansion exceeding this limit is
+invalid at computed-value time. Property-specific parsing, unit conversion, and layout
+support still determine which resolved values can be used by a consuming property.
diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs
new file mode 100644
index 00000000..d5f750e2
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs
@@ -0,0 +1,244 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Css.Parser;
+ using AngleSharp.Css.RenderTree;
+ using AngleSharp.Css.Values;
+ using AngleSharp.Dom;
+ using AngleSharp.Text;
+ using NUnit.Framework;
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using static CssConstructionFunctions;
+
+ [TestFixture]
+ public class CustomPropertyCompatibilityTests
+ {
+ [TestCase("visible", "visible")]
+ [TestCase("var(--b)", "hidden")]
+ public void OnlyComputedStylesResolveCustomProperties(String value, String expected)
+ {
+ using var document = ParseDocument("
");
+ var element = document.QuerySelector("div");
+ var child = document.QuerySelector("span");
+ var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice());
+ var explicitStyle = styles.ComputeExplicitStyle(element);
+ var declarations = styles.GetDeclarations(element);
+ var cascade = styles.ComputeCascadedStyle(child, declarations);
+ var builder = RenderTreeBuilder.GetInstance(document.DefaultView);
+ var rendered = builder.RenderElement(element, styles.Device);
+ var renderedChild = rendered.Children.OfType().Single();
+
+ foreach (var raw in new[] { explicitStyle, declarations, cascade, styles.GetDeclarations(child),
+ rendered.SpecifiedStyle, renderedChild.SpecifiedStyle, builder.GetElementStyle(child) })
+ {
+ Assert.AreEqual("var(--a)", raw.GetPropertyValue("--b"));
+ Assert.AreEqual("var(--b,hidden)", raw.GetPropertyValue("visibility"));
+ }
+
+ Assert.AreEqual(expected, styles.ComputeDeclarations(child).GetPropertyValue("visibility"));
+ Assert.AreEqual(expected, renderedChild.ComputedStyle.GetPropertyValue("visibility"));
+ Assert.AreEqual("var(--a)", declarations.GetPropertyValue("--b"));
+ }
+
+ [Test]
+ public void PublicFallbackParserPreservesNestedVariableObjects()
+ {
+ var source = new StringSource("var(--a,var(--b,red)))");
+ var outer = source.ParseVarFallback() as CssVarValue;
+ Assert.IsNotNull(outer);
+ Assert.AreEqual("--a", outer.VariableName);
+ var inner = outer.DefaultValue as CssVarValue;
+ Assert.IsNotNull(inner);
+ Assert.AreEqual("--b", inner.VariableName);
+ Assert.AreEqual("red", inner.DefaultValue.CssText);
+ Assert.AreEqual("var(--a, var(--b, red))", outer.CssText);
+ Assert.AreEqual(')', source.Current);
+ }
+
+ [Test]
+ public void ParsedReferencesPreserveNestedFallbackObjects()
+ {
+ var property = ParseDeclaration("visibility:var(--a,var(--b,var(--c,hidden)))");
+ var reference = (CssReferenceValue)property.RawValue;
+ var second = reference.References[0].DefaultValue as CssVarValue;
+ Assert.IsNotNull(second);
+ Assert.AreEqual("--b", second.VariableName);
+ var third = second.DefaultValue as CssVarValue;
+ Assert.IsNotNull(third);
+ Assert.AreEqual("--c", third.VariableName);
+ Assert.AreEqual("hidden", third.DefaultValue.CssText);
+ }
+
+ [Test]
+ public void PublicReferenceParserPreservesTheSourcePosition()
+ {
+ var source = new StringSource("var(--before) var(--after)");
+ source.NextTo("var(--before) ".Length);
+ var index = source.Index;
+ var reference = source.ParseVars();
+ Assert.AreEqual(index, source.Index);
+ Assert.AreEqual(1, reference.References.Length);
+ Assert.AreEqual("--after", reference.References[0].VariableName);
+ Assert.AreEqual("var(--before) var(--after)", reference.CssText);
+ Assert.AreEqual("after", ((ICssValue)reference).Compute(new TestComputeContext()).CssText);
+ }
+
+ [TestCase("var(--a)", false)]
+ [TestCase("var(--a,)", true)]
+ [TestCase("var(--a, )", true)]
+ public void EmptyFallbacksAreDistinctFromAbsentFallbacks(String text, Boolean hasFallback)
+ {
+ var source = new StringSource(text);
+ var reference = (CssVarValue)source.ParseVarFallback();
+ Assert.AreEqual("--a", reference.VariableName);
+ Assert.AreEqual(hasFallback, reference.DefaultValue is not null);
+ Assert.AreEqual(String.Empty, reference.DefaultValue?.CssText ?? String.Empty);
+ Assert.IsTrue(source.IsDone);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void DeepPublicFallbackTreesRemainIterative(Boolean parseDirectly)
+ {
+ const Int32 count = 8192;
+ var text = String.Concat(Enumerable.Repeat("var(--missing,", count)) + "visible" + new String(')', count);
+ var reference = parseDirectly ? (CssVarValue)new StringSource(text).ParseVarFallback() :
+ ((CssReferenceValue)ParseDeclaration("visibility:" + text).RawValue).References[0];
+ var current = reference;
+ var depth = 1;
+
+ while (current.DefaultValue is CssVarValue nested)
+ {
+ current = nested;
+ depth++;
+ }
+
+ Assert.AreEqual(count, depth);
+ Assert.AreEqual("visible", current.DefaultValue.CssText);
+ Assert.AreEqual(text.Replace(",", ", "), reference.CssText);
+ var context = new TestComputeContext { Converter = ParseDeclaration("visibility:visible").Converter };
+ Assert.AreEqual("visible", reference.Compute(context).CssText);
+ }
+
+ [Test]
+ public void DirectReferenceComputationUsesSuppliedAndMutableReferences()
+ {
+ var reference = new CssReferenceValue("var(--literal)", new[]
+ {
+ Tuple.Create(new TextRange(default, default), new CssVarValue("--supplied")),
+ });
+ var context = new TestComputeContext();
+ Assert.AreEqual("supplied", ((ICssValue)reference).Compute(context).CssText);
+ reference.References[0] = new CssVarValue("--modified");
+ Assert.AreEqual("modified", ((ICssValue)reference).Compute(context).CssText);
+ Assert.AreEqual("var(--literal)", reference.CssText);
+ }
+
+ [Test]
+ public void DirectReferenceComputationRetainsFirstSuccessfulReference()
+ {
+ var reference = new CssReferenceValue("var(--literal)", new[]
+ {
+ Tuple.Create(new TextRange(default, default), new CssVarValue("--missing")),
+ Tuple.Create(new TextRange(default, default), new CssVarValue("--supplied")),
+ Tuple.Create(new TextRange(default, default), new CssVarValue("--unused")),
+ });
+ var context = new TestComputeContext();
+ Assert.AreEqual("supplied", ((ICssValue)reference).Compute(context).CssText);
+ CollectionAssert.AreEqual(new[] { "--missing", "--supplied" }, context.Names);
+ }
+
+ [Test]
+ public void ModifiedParsedReferencesAffectComputedStyles()
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ var reference = (CssReferenceValue)element.GetStyle().GetProperty("visibility").RawValue;
+ reference.References[0] = new CssVarValue("--b");
+ Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ Assert.AreEqual("var(--a)", reference.CssText);
+ }
+
+ [TestCase("--b", "hidden")]
+ [TestCase("--alias", "collapse")]
+ public void ModifiedCustomPropertyReferencesParticipateInResolution(String name, String expected)
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ var reference = (CssReferenceValue)element.GetStyle().GetProperty("--alias").RawValue;
+ reference.References[0] = new CssVarValue(name);
+ Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ Assert.AreEqual("var(--a)", element.GetStyle().GetPropertyValue("--alias"));
+ }
+
+ [Test]
+ public void ConstructedCustomPropertyReferencesAreNotReparsedFromLiteralText()
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ ((CssProperty)element.GetStyle().GetProperty("--alias")).RawValue = new CssReferenceValue("var(--a)", new[]
+ {
+ Tuple.Create(new TextRange(default, default), new CssVarValue("--missing")),
+ Tuple.Create(new TextRange(default, default), new CssVarValue("--b")),
+ });
+ Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ }
+
+ [Test]
+ public void ModifiedShorthandReferencesParticipateInResolution()
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ var child = (CssChildValue)element.GetStyle().GetProperty("margin-top").RawValue;
+ var reference = (CssReferenceValue)child.Parent;
+ reference.References[0] = new CssVarValue("--b");
+ var computed = element.ComputeCurrentStyle();
+ Assert.AreEqual("3px", computed.GetPropertyValue("margin-top"));
+ Assert.AreEqual("4px", computed.GetPropertyValue("margin-right"));
+ }
+
+ [Test]
+ public void DirectVariableComputationRetainsFallbackOnFailedComputation()
+ {
+ var reference = new CssVarValue("--invalid", new CssIdentifierValue("fallback"));
+ var context = new TestComputeContext();
+ Assert.AreEqual("fallback", reference.Compute(context).CssText);
+ }
+
+ [Test]
+ public void ComputationDoesNotSuppressValueExceptions()
+ {
+ var reference = new CssVarValue("--throw", new CssIdentifierValue("fallback"));
+ Assert.Throws(() => reference.Compute(new TestComputeContext()));
+ }
+
+ private sealed class TestComputeContext : ICssComputeContext
+ {
+ public IRenderDevice Device { get; } = new DefaultRenderDevice();
+ public IBrowsingContext Context => null;
+ public IValueConverter Converter { get; set; }
+ public List Names { get; } = new();
+
+ public ICssValue Resolve(String name)
+ {
+ Names.Add(name);
+
+ if (name == "--invalid")
+ {
+ return new CssAnyValue("not a value");
+ }
+
+ if (name == "--throw")
+ {
+ throw new InvalidOperationException("Test exception");
+ }
+
+ return name == "--missing" ? null : new CssIdentifierValue(name.Substring(2));
+ }
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs
new file mode 100644
index 00000000..0d9a912d
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs
@@ -0,0 +1,415 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Css.RenderTree;
+ using AngleSharp.Css.Values;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using System;
+ using System.Collections;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Text;
+ using System.Threading.Tasks;
+ using static CssConstructionFunctions;
+
+ [TestFixture]
+ public class CustomPropertyCyclesTests
+ {
+ [Test]
+ public async Task OriginalReproductionDoesNotOverflow()
+ {
+ using var context = BrowsingContext.New(Configuration.Default.WithCss());
+ using var document = await context.OpenAsync(response => response.Content(
+ ""));
+ var style = document.QuerySelector("button").ComputeCurrentStyle();
+ Assert.AreEqual("rgba(0, 0, 0, 1)", style.GetPropertyValue("color"));
+ Assert.AreEqual(String.Empty, style.GetPropertyValue("--a"));
+ Assert.AreEqual(String.Empty, style.GetPropertyValue("--b"));
+ }
+
+ [TestCase("--a:var(--a)")]
+ [TestCase("--a:var(--a,visible)")]
+ [TestCase("--a:var(--b);--b:var(--a)")]
+ [TestCase("--a:var(--b,visible);--b:var(--a,visible)")]
+ [TestCase("--a:var(--b);--b:var(--c);--c:var(--a)")]
+ [TestCase("--present:visible;--a:var(--present,var(--a))")]
+ [TestCase("--present:visible;--a:var(--present,calc(var(--a)))")]
+ [TestCase(@"--a:var(--\61,visible)")]
+ [TestCase(@"--a:v\61 r(--a,visible)")]
+ [TestCase("--a:var(--b,var(--c));--b:var(--a);--c:var(--b,visible)")]
+ public void EveryCyclicMemberIsInvalid(String declarations)
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ element.SetAttribute("style", declarations + ";visibility:var(--a,hidden);--outside:var(--a,visible);display:block");
+ var style = element.ComputeCurrentStyle();
+ Assert.AreEqual("hidden", style.GetPropertyValue("visibility"));
+ Assert.AreEqual("visible", style.GetPropertyValue("--outside"));
+ Assert.AreEqual("block", style.GetPropertyValue("display"));
+
+ foreach (var name in new[] { "--a", "--b", "--c" })
+ {
+ Assert.AreEqual(String.Empty, style.GetPropertyValue(name), name);
+ }
+ }
+
+ [TestCase("var(--missing,hidden)", "hidden")]
+ [TestCase("var(--missing,var(--other,hidden))", "hidden")]
+ [TestCase("var(--missing,var(--other,var(--third,hidden)))", "hidden")]
+ [TestCase("var(--missing)", "visible")]
+ [TestCase("var(--missing,)", "visible")]
+ public void MissingVariablesAndNestedFallbacks(String value, String expected)
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ element.SetAttribute("style", "visibility:" + value);
+ Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ }
+
+ [TestCase("--a:var(--a)", "var(--a)")]
+ [TestCase("--a:12px", "var(--a,visible)")]
+ [TestCase("--a:var(--missing,)", "var(--a,visible)")]
+ public void InvalidAtComputedValueTimeUsesInheritanceNotPreviousDeclaration(String custom, String value)
+ {
+ using var document = ParseDocument("
");
+ var element = document.QuerySelector("span");
+ element.SetAttribute("style", custom + ";visibility:visible;visibility:" + value + ";width:10px;width:var(--missing)");
+ var style = element.ComputeCurrentStyle();
+ Assert.AreEqual("hidden", style.GetPropertyValue("visibility"));
+ Assert.AreEqual("auto", style.GetPropertyValue("width"));
+ }
+
+ [Test]
+ public void ResolvedTokensAreNotConvertedUsingTheCustomPropertyConverter()
+ {
+ using var document = ParseDocument("");
+ var style = document.QuerySelector("div").ComputeCurrentStyle();
+ Assert.AreEqual("0", style.GetPropertyValue("--a"));
+ Assert.AreEqual("0", style.GetPropertyValue("opacity"));
+ Assert.AreEqual("0", style.GetPropertyValue("width"));
+ }
+
+ [TestCase("--a:red;--b:var(--a);--c:var(--a);--d:var(--b) var(--c)", "red red")]
+ [TestCase("--d:var(--missing,)", "")]
+ public void AcyclicDiamondsAndEmptyFallbacksRemainValid(String declarations, String expected)
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ element.SetAttribute("style", declarations);
+ var style = element.ComputeCurrentStyle();
+ Assert.AreEqual(expected, style.GetPropertyValue("--d"));
+ Assert.IsNotInstanceOf(style.GetProperty("--d").RawValue);
+ }
+
+ [TestCase("--a:visible;--b:var(--a)", "--a:hidden", "visible")]
+ [TestCase("--a:var(--b);--b:var(--a)", "--a:visible", "hidden")]
+ [TestCase("--a:visible;--b:var(--a)", "--b:initial", "hidden")]
+ [TestCase("--a:visible;--b:var(--a)", "--a:hidden;--b:inherit", "visible")]
+ [TestCase("--a:visible;--b:var(--a)", "--a:hidden;--b:unset", "visible")]
+ public void InheritanceUsesTheParentsResolvedCustomValues(String parent, String child, String expected)
+ {
+ using var document = ParseDocument("
");
+ document.QuerySelector("div").SetAttribute("style", parent);
+ var element = document.QuerySelector("span");
+ element.SetAttribute("style", child + ";visibility:var(--b,hidden)");
+ Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ }
+
+ [TestCase("var(--b)", "hidden")]
+ [TestCase("hidden", "hidden")]
+ [TestCase("visible", "visible")]
+ public void SharedRulesAreStillLocalDeclarations(String childValue, String expected)
+ {
+ using var document = ParseDocument(
+ "
Child
Sibling
");
+ var child = document.QuerySelector("#c");
+ var sibling = document.QuerySelector("#s");
+ var parent = document.QuerySelector("#p");
+ var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice());
+ var parentStyle = styles.ComputeDeclarations(parent);
+
+ Assert.AreEqual(expected, child.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ Assert.AreEqual(expected, styles.ComputeDeclarationsWithParent(child, parentStyle).GetPropertyValue("visibility"));
+ var rendered = RenderTreeBuilder.GetInstance(document.DefaultView).RenderElement(parent, styles.Device);
+ var renderedChild = rendered.Children.OfType().Single(node => node.Ref == child);
+ Assert.AreEqual(expected, renderedChild.ComputedStyle.GetPropertyValue("visibility"));
+ var cascade = styles.ComputeCascadedStyle(child, parentStyle);
+ Assert.AreEqual(expected, cascade.Compute(new CssComputeContext(styles.Device, document.Context, cascade, parentStyle)).GetPropertyValue("visibility"));
+ Assert.AreEqual("visible", sibling.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ Assert.AreEqual("visible", parent.ComputeCurrentStyle().GetPropertyValue("visibility"));
+ Assert.AreEqual(expected, child.ComputeCurrentStyle().GetPropertyValue("visibility"));
+
+ using var inlineDocument = ParseDocument("
" +
+ "
");
+ Assert.AreEqual(expected, inlineDocument.QuerySelector("div div").ComputeCurrentStyle().GetPropertyValue("visibility"));
+ }
+
+ [Test]
+ public void InheritedOrdinaryValuesAreNotRecomputedAgainstChildVariables()
+ {
+ using var document = ParseDocument("