Skip to content

v1.0.10

Choose a tag to compare

@github-actions github-actions released this 11 Oct 04:13
· 1223 commits to main since this release
c615635

A 1.0 patch release with security fixes, including the fixes from 1.1.1 to 1.1.5 that the 1.0 line did not have; upgrading from 1.0.9 is recommended. The 1.0 line is no longer maintained, so moving to 1.2 or later is advised. Review these behaviour changes before upgrading:

  • Live transports (WebSocket, SSE, the HTTP POST fallback, the API, {% live_render %} children) refuse a view whose access check lives only in its own setup(), dispatch(), get() or post(), in a decorator on one of them or in a URLconf wrapper, until the view declares its rule (login_required, permission_required or check_permissions()). On this line the system check that names those views is djust.S004, the id it shares with the DEBUG/ALLOWED_HOSTS check.
  • With Django's LoginRequiredMiddleware installed, live mounts get the answer the middleware gives.
  • djust admin: staff users need the model's Django permissions (superusers have them all).
  • The /_djust/observability/ endpoints need the X-Djust-Observability-Token header (manage.py djust_observability_token prints it; the djust MCP server sends it).
  • Strings passed to component slots and attributes are escaped unless marked safe, as in 1.1.4.
  • Templates: the bare {{ value|linebreaks }} renders real <p>/<br> tags around escaped text, {{ value|escape|F }} for a plain filter F double-escapes, and a custom filter registered with is_safe=True escapes plain input.
  • A view whose mount() raises is no longer left mounted; a PermissionDenied there closes the socket with 4403.

Security

  • The live mount checks object permission after handle_params(). The WebSocket mount ran has_object_permission() after mount() but before handle_params(), so a view that selects its object from the URL's query parameters in handle_params() was checked against the object mount() chose and then rendered the one the parameters selected. The mount through the SSE message endpoint (ViewRuntime.dispatch_mount) ran no object check at all. Both now run mount(), handle_params(), the object check, and then the first render: the order the HTTP GET render and url_change navigation already used. A refused WebSocket mount still sends its error frame and closes with 4403; a refused SSE message mount sends a permission_denied error and mounts no view.

  • Live transports enforce or refuse access control that only the HTTP request runs.
    The WebSocket and SSE mount, events, the HTTP POST event fallback, the API
    and {% live_render %} children never call a view's setup(),
    dispatch(), get() or post(), and never run the wrappers the URLconf
    puts around as_view(). djust replays its own declarations and Django's
    LoginRequiredMixin, PermissionRequiredMixin and UserPassesTestMixin
    there, but a custom AccessMixin subclass that checks the user in its own
    dispatch(), a @method_decorator(...) on dispatch, get or post, an
    overridden method that checks the user, or a URLconf wrapper such as
    path("x/", login_required(MyView.as_view())) did not run at all.
    check_view_auth now:

    • replays Django's login_required, staff_member_required,
      user_passes_test(...) and permission_required(...) applied with
      @method_decorator, wrapped around as_view() in the URLconf (read from
      the server's URLconf, not from the URL the client sends) or by an
      as_view() override, and other packages' decorators built on
      user_passes_test. The same users are admitted as on the HTTP request;
      an authenticated user who fails the test gets PermissionDenied, where
      Django's HTTP response would redirect to the login page. Async test
      functions are awaited (called directly on the event loop's thread, the
      check refuses instead of blocking). login_not_required is not access
      control. A decorator applied only to post is checked for every event on
      the page (the view's, its components', its embedded views' and uploads),
      not for the mount;
    • refuses a view whose custom AccessMixin subclass defines dispatch(),
      unless it declares permission_required or check_permissions()
      (login_required = True alone does not cover it);
    • refuses a view whose setup()/dispatch()/get()/post() override, a
      decorator on one (also under another decorator stacked above
      @method_decorator, or returned by a project decorator that delegates to
      it), or a URLconf wrapper checks the user (any attribute of it, such as
      is_staff, groups or a role) and denies by any means (raises, including
      PermissionDenied and Http404; calls handle_no_permission() or
      redirect_to_login(); returns a redirect, an error response or a page of
      its own such as render(...) or HttpResponse(...); calls
      get_object_or_404()), unless a declared rule covers it (login_required covers a check that reads only
      is_authenticated / is_anonymous);
    • does not refuse a view for any other decorator or URLconf wrapper that
      is neither Django's nor djust's and whose code does not check the user (a
      CSP, rate-limit or logging decorator); it does not run on live
      transports.

    Permitted users of views using Django's login_required,
    staff_member_required, user_passes_test and permission_required
    decorators and wrappers, or its three auth mixins, are unaffected. Views
    whose access check lives in their own setup(), dispatch(), get(),
    post(), decorators or URLconf wrappers are refused for every user until
    the rule is declared. The HTTP GET page, which Django's dispatch already
    gated, is unchanged. System check djust.S004 (the dispatch-auth check; an Error, so
    manage.py check and migrate stop until the view is fixed; on this release
    line the id is shared with the DEBUG/ALLOWED_HOSTS check) names each refused
    view defined in the project. Overrides that only read the user, unknown
    decorators and wrappers, refused views that an installed package defines and
    URLconf routes whose wrapper does not use functools.wraps are not
    reported. djust.S004 no longer reports Django's own decorators on
    dispatch(), which are replayed. Only djust and
    djust.* modules are treated as framework code: apps and packages named
    djust_* are inspected, djust-auth's LoginRequiredLiveViewMixin is read
    as login_required = True, and djust-tenants' TenantMixin is not
    inspected. LoginRequiredLiveViewMixin in djust.auth now also sets
    login_required = True.
    Upgrade note: a view refused this way must declare its rule;
    login_required = False is not a declaration.

  • MCP tools keep filesystem paths inside the project.
    find_handlers_for_template read any absolute path that existed, so it
    confirmed which files existed anywhere on disk and returned dj-*-shaped
    strings from them. It now reads an absolute path only when the file resolves
    (symlinks and .. followed) inside a directory the template loaders serve or
    inside BASE_DIR, and answers any other absolute path like a missing
    template. seed_fixtures compared paths with a string prefix, so
    /app-data passed for a BASE_DIR of /app, and skipped the check when
    BASE_DIR was unset; it now compares whole path components and refuses to
    load anything without BASE_DIR. Both use one containment helper.

  • Component content is escaped on every render path (1.0 backport of the 1.1 component escaping for these components). MasonryGrid / {% masonry_grid %} item content and DashboardGrid / {% dashboard_grid %} panel content are escaped by the component class, the Django template tag and the Rust-engine handler; AuditLog / {% audit_log %} escape column names in each cell's class attribute; {% file_dropzone %} escapes name in the element id; Meter / {% meter %} escapes segment values in the legend and ARIA attributes; RichTextEditor / {% rich_text_editor %} cleans value to the Markdown component's tag and attribute allowlist (values marked safe pass through unchanged), so stored rich text keeps its formatting while scripts, event-handler attributes and non-http(s)/mailto URLs are removed (djust.components.utils.rich_html). The Rust engine passes list arguments to component handlers as data, so markup marked safe inside items is escaped there too; render such items with the component class or the Django tag. The MasonryGrid and DashboardGrid docstrings and the gallery examples now show text content. Tests in python/djust/tests/test_rust_handler_item_escaping.py.

  • Built-in components HTML-escape the values they render (1.0 backport of the 1.1.4 component escaping). Component classes, {% ... %} component tags, their Rust-engine handlers and the Rust component renderers now pass interpolated values through conditional_escape, in all CSS-framework variants; the relation selects (ForeignKeySelect, ManyToManySelect) escape model-derived labels. Strings passed to component slots and attributes are escaped unless marked safe: HTML passed to a content slot must be marked safe (mark_safe, or another component's rendered output), or it is shown as text. This affects modal, card, sheet, popover, tabs and accordion bodies, headers and footers, icons, and similar slots. href/src values using javascript:, vbscript: or non-image data: render as #. The rich-text editor is the exception: its value is cleaned to an HTML allowlist (formatting kept, scripts and event attributes removed) instead of escaped. This is the same behaviour change 1.1.4 made.

  • Theming URLs use a shared script-scheme denylist (backported from #3400, #3407, #3420). Data-derived URLs in navigation, sidebar sections, breadcrumbs, pagination, avatars and auth/error/empty-state pages are neutralised to "#" for javascript:, vbscript: and data: schemes, including whitespace and control-character obfuscation. Avatar image sources retain data:image/*; links do not. Other schemes, including sms:, geo: and app deep links, remain unchanged. Developer-supplied button href= and href/src/action/formaction attributes passed to button, input, select and progress tags raise ValueError. Dictionary and object items in iterable collections are checked without mutating caller-owned items; pagination checks every formatted page, first, last, previous and next URL. Regression tests: python/djust/tests/test_theming_url_scheme_guard.py.

  • @rate_limit now applies on the page-POST fallback. The per-handler @rate_limit was checked on WebSocket/SSE and on the HTTP API, but the page-POST fallback (mixins/request.post), which also runs an @event_handler from the request body, did not consult it, so calls through it did not use the shared per-caller budget that F27 documents as "one budget per handler regardless of transport". The fallback now checks the limit at the same point as the WebSocket path (after the decorator and permission checks, before the handler runs) and answers 429 with code: "rate_limited" in its existing JSON error shape. Both HTTP transports share one implementation, djust.rate_limit.http_handler_rate_check, and one caller-key helper, caller_key_for_request (still behind resolve_client_ip, so DJUST_TRUSTED_PROXY_COUNT still applies); the HTTP API's behaviour is unchanged. Tests: python/djust/tests/test_ratelimit_post_fallback.py.

  • One @rate_limit budget per handler function, whatever name reaches it. The shared store keyed buckets on the caller and the name the route used (the client-supplied event_name on WebSocket/SSE, the function name on the HTTP API), so a method reachable under two names (an alias attribute, or a second name bound in mount) had a separate budget per name. The bucket is now keyed on the function that actually runs (__func__ of a bound method), accepted only when that function is declared on the owning class; only the exact builtin staticmethod/classmethod wrappers are unwrapped, and membership is compared with is. A handler whose identity cannot be determined this way (a property or other descriptor, a functools.partial, a callable instance, a function from an unrelated class) is refused with a named error rather than given a budget of its own, and two handlers that resolve to one key with different limit settings are refused rather than sharing the first one's settings. Functions made by one factory keep separate budgets. Tests: python/djust/tests/test_ratelimit_post_fallback.py.

  • Components check the CSS values they write into style attributes. A colour, length, ratio or number that a component takes from its caller and writes into an inline style attribute is now checked against the shapes the component documents, and anything else is replaced by the component's default. Colours accept #rgb/#rgba/#rrggbb/#rrggbbaa, a CSS named colour (with transparent and currentcolor), rgb()/rgba()/hsl()/hsla() with plain numeric arguments, and var(--name) references; lengths accept numbers with a unit, the usual keywords, var(), and calc()/min()/max()/clamp(). Valid values render exactly as before. Covers CursorsOverlay and CollabSelection (the user colour), CalendarView event colours, Meter segment colours, ColorPicker, kanban column colours, the data table colour scale, and the heights, widths, gaps, offsets and ratios of MapPicker, RichTextEditor, ScrollArea, StickyHeader, DataGrid, DataTable, ResizablePanel, SortableGrid, DashboardGrid, AspectRatio, ResponsiveImage, SplitPane, the progress components and {% theme_skeleton %}, on every render path (component class, template tag and Rust template engine). The helpers are djust.components.utils.safe_css_color, safe_css_length, safe_css_ratio and safe_css_number.

  • Live transports follow Django's LoginRequiredMiddleware. With django.contrib.auth.middleware.LoginRequiredMiddleware (Django 5.1+, or a subclass) in MIDDLEWARE, a view mounted over a live transport (WebSocket, SSE, API dispatch, mount_batch) now gets the answer the middleware gives for its page: an anonymous user is sent to the login page, where the middleware would send them (its login_url and redirect_field_name for that route), unless the view is public. A view is public when its dispatch carries Django's login_not_required (@method_decorator(login_not_required, name="dispatch"), public everywhere), or when the URLconf routes that view class through login_not_required(MyView.as_view()), or when the request's own route serves the view and the installed middleware (subclass exemptions included) lets it through. A view the URLconf does not route, mounted beside the page view or hydrated as a lazy child over the WebSocket, needs the dispatch marker to be mounted anonymously; naming a public page's URL does not make it public. When the middleware refuses such a view, only that view is refused (an error frame with code: "login_required", or a failed[] entry in the mount_batch reply) and the page stays where it is; the first view mounted on a socket is the page view, and its refusal sends the page to the login page. That is the middleware's answer: a view refused by its own login_required = True still sends the page to the login page, and a view that relies only on permission_required, check_permissions() or Django's auth mixins gets the middleware's answer first, so beside the page view it is refused on its own. On SSE and the API the request is the endpoint's, so a view served there anonymously needs the dispatch marker or a login_not_required route of its own (and the endpoint itself must be exempt from the middleware for the request to arrive). Views rendered inside a page ({% live_render %} children) take their page's answer, as Django never checks what a page includes, and a page's own HTTP GET and POST are left to the middleware itself. djust's own login_required = False is not an exemption, as it is not for the middleware. Without the middleware, or on Django < 5.1, nothing changes.

  • A refused WebSocket mount no longer joins the view's channel groups. LiveViewConsumer.handle_mount joined the view's server-push group, its presence group and its db_notify groups, and started its tick, before the pre-mount auth checks ran. A view refused inside a mount_batch leaves the socket open, so the connection kept receiving that view's presence broadcasts. The groups are now joined only once auth and the on_mount hooks admit the view, the tick starts once the object-permission check has passed, and a mount refused by that check leaves the groups and drops the presence its mount() tracked. presence_event and client_push_event are dropped when the connection has no mounted view, as server_push and db_notify already were.

  • A live view whose mount() raises is no longer left mounted. An exception from mount() (or the state restore, handle_params() or the first render) used to answer with an error frame and keep the view as the WebSocket connection's mounted view, so later events still ran its handlers, and it stayed in its channel groups with its presence tracked. Such a mount now ends like a refused one: a PermissionDenied is answered as the authorization check answers it (Permission denied; the page view's socket closes with 4403), any other exception keeps its error frame, and the view is released, its groups are left, its presence is untracked and its tick stops. A view mounted beside the page view (dj-lazy, a mount_batch entry) goes alone and the page view stays. A view refused by the pre-mount permission check or by its object permission is cleared from the connection as well. Over SSE a failed mount also untracks its presence. The HTTP fallback and the HTTP API already refused such a request.

  • Theme state read from cookies keeps only registered pack names and well-formed layout names, and {% theme_css_link %} encodes its URL (1.0 backport of the 1.1.4 fix). ThemeManager.get_state() now keeps a djust_theme_pack value only when that pack is registered; any other name falls back to the session pack, then the configured default, then no pack. Layout names must be simple identifiers ([A-Za-z0-9_-], up to 64 characters), otherwise the base layout is used. {% theme_css_link %} (and its Rust-engine handler) builds the query string with urlencode and returns an HTML-escaped URL, so a cookie value can no longer leave the href attribute it is written into.

  • The djust admin applies the DjustModelAdmin permission hooks on the server (1.0 backport of the 1.1.4 fix). The change list needs view or change permission; the add page and its save need add permission; the change page needs view or change permission and its save needs change permission; the delete page, confirm_delete and the built-in delete_selected action need delete permission. The checks run on mount and inside the save and delete handlers, so they hold on every transport. As in Django's ModelAdmin, the default hooks now check request.user.has_perm("<app_label>.<action>_<model_name>") instead of returning True, and the admin index lists only models the user holds some permission on. Behaviour change: staff users need the model's Django permissions (superusers have them all); override the hooks for other rules.

  • The /_djust/observability/ endpoints serve only direct local requests that carry the project's observability token (1.0 backport of the 1.1.4 fix). Besides DEBUG and a loopback REMOTE_ADDR, a request must carry none of the headers a reverse proxy adds (X-Forwarded-For, Forwarded, X-Real-IP, X-Forwarded-Host, X-Forwarded-Proto) and must send the X-Djust-Observability-Token header: an HMAC of SECRET_KEY, or DJUST_OBSERVABILITY_TOKEN from the environment when set. A reverse proxy on the same host connects from loopback, so the peer address alone did not show where a request came from. LocalhostOnlyObservabilityMiddleware applies the same network check. The djust MCP server sends the token automatically when started with manage.py djust_mcp; manage.py djust_observability_token prints it for other tools.

  • A reused sticky {% live_render %} child is re-authorized on every parent render (1.0 backport of the 1.1.4 fix). When a parent re-render reused a live sticky=True child, the view-level and object-level checks that a fresh embed runs were skipped, so a child kept rendering after the user lost access to it. The reuse path now runs check_view_auth and enforce_object_permission against the current request; on denial the child is dropped from the parent (its cleanup hooks run) and the embed is refused, exactly as a fresh mount would be. Sticky children carried across a live_redirect are also checked at object level before they are kept.

  • Resumable uploads record and check their owning session (1.0 backport of the 1.1.4 fix). upload_register now stores the connection's Django session key on the upload, and a ResumableUploadWriter records it as the owner. upload_resume (WebSocket) and the HTTP upload-status view answer not_found unless the requesting session is that owner; an upload with no recorded owner session cannot be resumed. Previously the owner was never recorded, so the ownership check never applied and any connection that knew an upload id could read its progress.

  • Six template-layer escaping defects closed (1.0 backport of the 1.1.1 fixes). {{ value|unordered_list }} and {{ value|safeseq }} on a string now escape it; {% render_slot %} escapes a plain context value it would otherwise echo (a slot entry's pre-rendered content stays as rendered); a mark_safe grant on a RustLiveView key is revoked when update_state replaces that key's value, so it no longer outlives the value for the life of the connection; |escape escapes instead of being a no-op, so {{ value|escape|safe }} is inert; linenumbers, linebreaks and linebreaksbr escape each line before adding their own markup, so {{ value|linebreaks|safe }} is inert and the bare {{ value|linebreaks }} now renders real <p>/<br> tags around escaped text. These four filters report their output as already escaped only at the last position in the chain, so {{ value|escape|F }} for a plain filter F double-escapes (safe direction, same as 1.1.1). Tests in python/djust/tests/test_security_1_1_1_backports.py.

  • A template binding replaces the safety grant of the name it binds instead of inheriting it (1.0 backport of the 1.1.2 fix). {% with %}, {% for %} (including tuple unpacking), {% include ... with %} and custom assign tags ({% tag ... as x %}) bound a new value to a name that the context had marked safe (and the names beneath it, such as x.field) without dropping that mark, so the new, unescaped value was emitted raw. A bind now revokes the grants on the bound name and its descendants, scoped to the bind's own context; grants a value legitimately carries (the {% for %} loop mapping, an untouched outer name) are kept. Tests in python/djust/tests/test_security_1_1_2_binding_grant.py.

  • Custom template filters registered with is_safe=True keep their output escaped unless their input was already safe (1.0 backport of the 1.1.4 fix). The Rust renderer treated is_safe=True on a custom filter as "this filter's output is safe", so a filter returning a plain str built from user input rendered it unescaped on every djust render path. It now follows Django's rule (is_safe and isinstance(value, SafeData)): the flag keeps a safe input safe through the filter and never makes plain input safe; a filter that returns a SafeString (or mark_safe output) is still emitted as returned. Tests in python/tests/test_custom_filter_is_safe_requires_safe_input.py.

  • React component props are serialised as encoded JSON (1.0 backport of the 1.1.5 fix). Props were written into the single-quoted data-react-props attribute with only double quotes escaped, so a prop value containing ' ended the attribute and the rest of the value was parsed as markup. The Rust renderer and the Python post-processing path now serialise props with json.dumps-equivalent encoding and fully HTML-escape the attribute value; the client reads the attribute through dataset, which decodes the entities, and parses it as JSON. Tests in python/djust/tests/test_react_props_serialization.py.

  • DataTable sorts, filters and groups only on declared columns (1.0 backport of the 1.1.4 / 1.2.0rc11 change). on_table_sort, on_table_filter, group-by and expression filters now accept only keys listed in table_columns, checked again where the queryset is built. Sorting is on unless a column sets "sortable": False; filtering needs "filterable": True; table_default_sort is always accepted. Unknown column names are ignored, so the client can no longer filter or order a table's queryset on a field the table does not declare, including fields reached through relations. Tests in python/tests/test_data_table_declared_columns.py.

  • The djust admin change list filters and sorts only on declared fields. ModelListView.apply_filter accepts the field names in the ModelAdmin's list_filter (get_list_filter()), and sort_by accepts the sortable list_display columns and the fields named in ordering (get_ordering()), as Django's admin does. The queryset applies the same check to the stored filters and ordering, so any other name (another lookup, a path through a relation, an expression) is ignored and logged at debug level. The filter list on the page now comes from get_list_filter() as well. A custom change-list template that filters or sorts on other fields must declare them in list_filter, list_display or ordering. The auth admin's social accounts list sorts only on its own columns.

  • The djust admin loads rows through the ModelAdmin's get_queryset(). A DjustModelAdmin that scopes rows in get_queryset(request) was honoured by the change list only: bulk actions ran on the selected primary keys through Model.objects, and the change and delete views loaded their object with Model.objects.get(). As in Django's admin, an action now runs on the selected rows that get_queryset(request) holds (other selected keys are ignored), and the change and delete views load their object with the new DjustModelAdmin.get_object(request, object_id), which looks it up in that queryset; a row it leaves out, or a key that names no row, is a 404.

  • crossbeam-epoch 0.9.18 → 0.9.20 (RUSTSEC-2026-0204, backported from #2125). An invalid pointer dereference in the fmt::Pointer impl for Atomic/Shared. Transitive (through rayon-core), lockfile-only, no manifest change.

Changed

  • The VDOM bounds the nesting depth of the HTML it parses. Every walker over the VDOM (diff, patch serialization, serialization back to HTML) recurses once per nesting level, so a page whose markup nests thousands of elements deep needs more stack than the rendering thread has. The parser now keeps at most MAX_VDOM_DEPTH (256) levels, counted from the LiveView root: an element at the limit keeps its tag and attributes, and everything below it is flattened to its text content, so nothing nests deeper and no text is lost (except below an SVG or MathML <style> or <script> at the limit, which keeps no text: its descendants' text is not its own). Pages are unaffected: the deepest of 92 rendered demo-project pages and 316 template files nests 14 levels, and parsing, diffing and serializing them gives byte-identical results before and after. The search for the dj-root element no longer walks the raw tree recursively either. The limit is exported as djust._rust.MAX_VDOM_DEPTH. Regression cases in python/tests/test_vdom_nesting_bound.py and crates/djust_vdom/tests/test_nesting_depth_bound.rs.
  • Terminal's ANSI conversion keeps a bounded number of styles open per line. Each open style is a nested <span> until a reset or the end of the line, so a short line could nest as deep as it was long. The conversion (in the Terminal component, the inline handler and the {% terminal %} tag) now keeps at most 32 styles open per line and opens styles from at most the first 16 parameters of a sequence (the client hook reads the same 16); a style past either cap is dropped, and a reset anywhere in a sequence closes every open style. Lines that stay under the caps, which is every line a program prints in practice, render byte for byte as before. Regression cases in python/tests/test_terminal_ansi_span_cap.py.
  • Markdown output keeps at most 32 containers open at once. djust.render_markdown and {% djust_markdown %} emit one <blockquote> per > and a <ul>/<li> pair per list level, so the depth of the output, and the time spent parsing and diffing it, grew with the nesting of the source. A block quote, list or list item that starts while MAX_MD_NESTING (32) containers are already open is now rendered without its own tags; its content stays in place inside the innermost kept container, so no text is lost, every kept <li> stays balanced, and the cost of rendering stays proportional to the length of the source. Documents that nest 32 containers or fewer (ordinary documents: 914 distinct Markdown files from the docs of this repository and its sibling projects all render unchanged, and the deepest nests 6) render byte for byte as before. The bound is exported as djust._rust.MAX_MD_NESTING. Regression cases in python/tests/test_markdown_nesting_bound.py and the markdown unit tests of djust_templates.

Fixed

  • The HTTP POST event transport now enforces the same authorization as every other transport. post() applied only the event-name format check and the @event_handler decorator policy; the three authorization layers that get() and the WebSocket/SSE event paths apply — view-level login_required / permission_required, handler-level @permission_required, and the ADR-017 object-level check — were not consulted, so an event could be dispatched to a caller the other transports would refuse. All three now run before dispatch, via the same check_view_auth / check_handler_permission / enforce_object_permission entry points the other paths use.
  • A malformed request body no longer masks its own cause. post()'s except block referenced event_name and params, which are assigned inside the try, so a failure before their assignment (a body that is not valid JSON is the easy one) raised UnboundLocalError from the error path itself — reporting a controlled failure as an unexplained 500 and never logging the real exception. Both names are now initialised before the try.
  • Test Git fixtures no longer inherit repository execution variables from Git hooks (#3179). Fixture helpers isolate their environment, Git-spawning test modules strip GIT_EXECUTION_VARS, and a root fixture covers library code that runs Git in-process. Static and behavioural guards prevent fixture identity and signing settings from leaking into the real repository's .git/config.
  • Text in an SVG or MathML <style> / <script> is serialized as escaped text. The VDOM wrote the text of any element named script or style verbatim, by tag name alone. The HTML serialization rules apply that only to HTML elements: inside <svg> or <math> these are foreign elements, whose text the parser decodes, so it has to be escaped again when written back (&lt; stays &lt;). The serializer now tracks the namespace while it writes (svg and math switch to foreign content; foreignObject, desc, title, MathML text elements and HTML annotation-xml switch back), so re-parsing the HTML gives the same tree. HTML <style> and <script> output is byte-identical, and so is SVG CSS without <, > or &. HTML <script> and <style> content is written verbatim exactly as the HTML serialization algorithm writes it, including a script that holds </script> inside <!-- … --> (the legacy document.write pattern). An inserted {% if %} block (the HTML of an InsertSubtree patch) is written the way the page writes it, since the client now parses it in its parent's namespace. The render_binary_diff text-only fast path now updates a text node only when the HTML's text runs and the tree's text nodes line up one for one, and never inside a raw-text element: it mapped changes by byte offset, so dropped whitespace or text outside the root sent a change to the neighbouring node, and it decoded &lt; inside a <script>. The text fast paths (the in-place update, try_text_only_vdom_update, try_text_patches, and the fragment and text-region paths of render_with_diff) also require each text node to sit in the element the source has open around its text: text directly inside a <table> is moved before the table by the parser, so two equal texts could line up in the wrong order and a change was applied to the other node. Such updates, and any start tag the parser would move out of a table, now take the full parse. The text fast path also falls back to a full parse for every <style> or <script> that follows an <svg> or <math> start tag; it used to count </svg> / </math> to decide when foreign content closed, and the tree builder ignores some of those end tags (a stray </math> inside <svg>, an </svg> inside foreignObject or desc content), so a text change could still be sent to the wrong node. The serializer also starts from the real namespace of the root: the parser reports it, so a dj-root on an element inside <svg> or <math> (<svg><g dj-root>) writes its <style> / <script> text escaped, as does its dj-update="ignore" cache, and the HTML of an InsertSubtree patch is written for its parent's namespace; the text fast paths run only under an HTML root. Regression cases in python/tests/test_vdom_foreign_raw_text.py, python/tests/test_vdom_html_raw_text_verbatim.py and in crates/djust_vdom/src/lib.rs.
  • An inserted {% if %} block inside <svg> or <math> keeps the SVG / MathML namespace. The client parsed the HTML of every InsertSubtree patch in an HTML <template>, so the elements of a conditional block inside <svg> or <math> came out as HTML elements, while the full render has them as SVG or MathML elements: an <iframe>, <a>, <form>, <details> or <video> written there is inert in the full render but was a working HTML element after the patch. The client now parses that HTML as children of the element it is inserted into: inside an <svg> or <math> wrapper for an SVG or MathML parent (a MathML mi, mo, mn, ms, mtext or annotation-xml parent is reproduced as well), and as HTML, as before, for an HTML parent and for the HTML integration points (foreignObject, desc, title, and annotation-xml with an HTML encoding). The server writes that HTML for that parse. Regression tests in tests/js/insert_subtree_namespace.test.js, python/tests/test_vdom_foreign_raw_text.py and crates/djust_vdom/src/lib.rs; tests/playwright/test_insert_subtree_namespace.py compares real patches with a full render in Chromium.
  • A node inserted inside <svg> or <math> by an InsertChild patch keeps the SVG / MathML namespace. The client chose an inserted element's namespace from its name alone: an allow-listed HTML name (iframe, a, form, details, video) inside <svg> or <math> became a working HTML element where the full render has an inert SVG or MathML element, and MathML names became a <span>. The client now derives the namespace from the element the node is inserted into, by the same rules a page parse follows (foreignObject, desc, title, the MathML text elements and HTML annotation-xml hold HTML), and creates allow-listed names, MathML element names included, in that namespace. Names outside the allow lists still become a <span>, and an element under an HTML parent is created as before. Regression tests in tests/js/insert_subtree_namespace.test.js; tests/playwright/test_insert_subtree_namespace.py covers InsertChild in Chromium too.
  • A dj-root on an SVG or MathML element keeps its children's namespace through html_update, html_recovery and the morph of a prerendered page on mount. These three paths parsed the root's inner HTML in an HTML context (DOMParser, or innerHTML on a <div>), so every child of a dj-root placed on an SVG or MathML element came out as an HTML element and replaced the SVG / MathML element of the full render: an <iframe>, <a>, <form>, <details> or <video> written there was a working HTML element after the update, where the full render has an inert SVG or MathML element. html_update and html_recovery now parse the frame as the root's children in the root's namespace, inertly (_parseRootFrameHtml), and the mount morph parses it in a detached element of the root's own namespace and name (_parseRootHtmlLive). A dj-root on an HTML element parses exactly as before. Regression tests in tests/js/full_html_frame_namespace.test.js.
  • The mount morph no longer runs a MathML or SVG <script>. After the morph of a prerendered page, the client re-creates each classic <script> in the dj-root as a new HTML <script> so that inline page scripts run (#1848). It also matched <script> elements inside <math> and <svg> and re-created them as HTML scripts, so a MathML <script>, which never runs in a full render, ran after the morph. _runInsertedScripts now re-creates only HTML <script> elements. Regression test in tests/js/mount-morph-script-exec-1848.test.js.
  • The children of a placeholder for an SVG or MathML element keep their namespace. An InsertChild patch turns an SVG or MathML element outside the client's allow lists (filter, fe*, animate*, set, mstack, an unknown name) into a <span>, and then created its children inside that HTML <span>: an <iframe>, <a>, <form>, <details> or <video> there became a working HTML element, where the full render has an inert SVG or MathML element. The children are now created in the namespace of the element the <span> stands in for. The placeholder itself stays a <span>. Regression tests in tests/js/insert_subtree_namespace.test.js.

What's Changed

Full Changelog: v1.0.9...v1.0.10