Skip to content

feat: Add expression serializer for AttributeGenerator - #54

Merged
gazpachoking merged 9 commits into
starfederation:developfrom
kdheepak:kd/attribute-expression-serialization
Sep 19, 2026
Merged

gazpachoking merged 9 commits into
starfederation:developfrom
kdheepak:kd/attribute-expression-serialization

Conversation

@kdheepak

@kdheepak kdheepak commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

This PR extracts the expression serializer parts of the draft PR from #13

Instead of the existing _js_object, in this PR a recursive serializer is used. Specifically, the previous _js_object helper recursively handled dictionaries, but inserted other values using their Python string representation.

This PR introduces a javascript() helper which:

  • recursively handles strings inside lists and tuples as expressions;
  • serializes True, False, and None as JavaScript;

For example, if a user were to write this code:

import htpy as h
from datastar_py.attributes import attribute_generator as ds

viewport = h.div(
    ds.signals(
        {
            "viewport": {
                "dimensions": ["window.innerWidth", "window.innerHeight"],
                "position": ("window.scrollX", "window.scrollY"),
                "fullscreen": False,
            }
        },
        expressions_=True,
    )
)

Before this PR:

<div data-signals='{
    "viewport": {"dimensions": ["window.innerWidth", "window.innerHeight"], 
    "position": ("window.scrollX", "window.scrollY")}
    "fullscreen": False,
}'></div>

After this PR:

<div data-signals='{
    "viewport": {"dimensions": [(window.innerWidth), (window.innerHeight)], 
    "position": [(window.scrollX), (window.scrollY)]}
    "fullscreen": false,
}'></div>

In this example, previously the signal would be ["window.innerWidth", "window.innerHeight"] which would be the string text but with this PR the signals will be [window.innerWidth, window.innerHeight], i.e. the value at the time.

Comment thread Makefile Outdated
Comment on lines +228 to +232
return BaseAttr(
"attr",
value=javascript(_as_javascript_expressions(attrs)),
alias=self._alias,
)

@kdheepak kdheepak Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe _as_javascript_expressions(attrs) is not needed for attr and class_ since we don't expect it to be recursive and this line could be done with a dictionary comprehension instead. e.g.

return BaseAttr(
    "attr",
    value=javascript({key: JSExpression(value) for key, value in attrs.items()}),
    alias=self._alias,
)

But I left it with _as_javascript_expressions since it felt cleaner this way.

Comment thread src/datastar_py/attributes.py Outdated
@kdheepak
kdheepak force-pushed the kd/attribute-expression-serialization branch from c503f64 to 081be82 Compare September 13, 2026 04:53
Comment thread tests/test_django_decorator_typing.py
@gazpachoking

gazpachoking commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Thanks for working on this! One thing I've been uncomfortable with since I wrote it is the global flag to turn everything from a string to js expressions at once. I'm wondering if we tackle that at the same time to simplify this. Rather than the expressions_ flag, we could require the user to manually wrap expressions, like data.signals(sig1=JSExpr("window.height")) or so. Then we could just do some simple string replacement on the json dump rather than having to do any fancy creation of js objects or recursive stuff ourselves.

_SENTINEL_START = "⟪JS:"
_SENTINEL_END = ":JS⟫"


class JSExpr(str):
    def __new__(cls, expr: str):
        return super().__new__(
            cls,
            f"{_SENTINEL_START}{expr}{_SENTINEL_END}",
        )

def dumps_js(value) -> str:
    result = json.dumps(value, separators=(",", ":"))
    return re.sub(
        rf'"{re.escape(_SENTINEL_START)}(.*?){re.escape(_SENTINEL_END)}"',
        lambda m: json.loads(f'"{m.group(1)}"'),  # to unescape quotes and stuff when the dump turned it into a string
        result,
    )

If we wanted backwards compat for the global way, we could have it just recurse the object wrapping every str with a JSExpr when expressions_ was true.

What do you think, is that a meaningful simplification? Worth it to be able to have mixed strings and expressions?

@kdheepak

kdheepak commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

I like this data.signals(sig1=JSExpr("window.height")) and I think we can do this right now, and do it without introducing any special markers or string replacements (which can match incorrectly). I'll push an update shortly to confirm.

@kdheepak
kdheepak force-pushed the kd/attribute-expression-serialization branch from 0dca7ee to b2c428c Compare September 18, 2026 04:29
@kdheepak

Copy link
Copy Markdown
Contributor Author

Well, the current implementation already supports this, just updated the type checking support to allow for this explicitly. And added a test to confirm:

assert ds.signals(
        literal="window.innerWidth",
        expression=JSExpression("window.innerWidth"),
) == {"data-signals": ('{"literal": "window.innerWidth", "expression": (window.innerWidth)}')}

@gazpachoking

Copy link
Copy Markdown
Collaborator

Cool, the user api is the same either way then. I just thought the regex implementation was a bit shorter to read and more performant. I'm fine with your way if you prefer it. The performance difference was something like 2-5x better with the sentinel way, but that really doesn't matter much when we won't realistically be calling it that many times per render, and the baseline time is really small.

@kdheepak

kdheepak commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

I like this approach only because we don't have to escape the sentinel values in an edge case where someone wants to display the sentinel values by storing that literal string in a signal. Maybe I'm overly cautious there :) I agree about performance not mattering here. If someone is interested in performance they can construct the string manually and we should focus on ergonomics here.

If you are okay with it, then this PR is ready to merge!

@gazpachoking
gazpachoking merged commit 0d100f6 into starfederation:develop Sep 19, 2026
2 checks passed
@kdheepak
kdheepak deleted the kd/attribute-expression-serialization branch September 19, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants