Migrate from django-components
If you want to move an existing django-components project to Citry, start with one leaf component, keep the existing page working, and use the checklist below to find every django-components pattern that needs attention.
This guide compares Citry's current documented behavior with the django-components source at commit 5d4d4f5 from June 20, 2026. If your project uses another django-components version, check its release notes for additional differences. Select the documentation version that matches the Citry version installed in your project.
Choose how to migrate
For a gradual migration inside Django, use citry-django to place Citry components in Django templates and use Django template tags in Citry components. It is a community integration, so follow its README for the versions and setup it supports.
For a direct port, render Citry from its Django integration and replace a complete component subtree at a time. The two integrations solve different problems: Citry's integration mounts rendering, assets, and event routes; citry-django lets the two template languages coexist while you migrate.
Before changing components:
- Create a branch and run the existing Python, browser, and snapshot tests.
- Inventory component directories, settings, custom template tags, extensions, JavaScript hooks, caches, and
Component.Viewsubclasses. - Install Citry, create one
Citryinstance, connect it to Django, and register or discover one leaf component. - Port that component and its tests. Verify it in the browser before moving to the next connected group.
- Remove django-components only after searches and tests show that nothing still depends on it.
Citry intentionally does not include django-components compatibility aliases or silent shims. A leftover pattern should be migrated explicitly.
How to read the checklist
Every known user-visible divergence is included below. Use the stable DJC-### identifier when tracking work or asking for help.
- 🔴 Breaks: the project will fail until the pattern is changed.
- 🟡 Check behavior: output or runtime behavior may differ.
- 🟢 Update tests: browser behavior is equivalent, but exact output or an assertion may need updating.
Search signatures are written in the django-components column. Not every row applies to every project, so record non-matches as not applicable instead of making speculative changes.
HTML attributes
Rewrite attribute merging first because these differences are easy to find and can silently change output.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-001 | Merging HTML attributes | The {% html_attrs %} tag (positional args, attrs: / defaults: aggregate keys, spread) | Element-level attributes: c-bind="mapping" to spread, plus c-class and c-style | Rewrite {% html_attrs attrs defaults class=... %} as <div c-bind="defaults" c-bind="attrs" c-class="...">. Attributes apply left to right and the later one wins, so put the fallback mapping first and the caller's mapping after it. class and style merge instead of overwriting. A leftover attrs:foo= is not rejected: it arrives as an input literally named attrs:foo, so search for : in attribute names. | 🔴 |
| DJC-002 | Repeated non-class/style attribute keys | The same key supplied twice is space-joined (foo="bar baz") | Last-one-wins (foo="baz") | If you relied on a repeated plain attribute concatenating, combine the value yourself. class and style still merge. | 🟡 |
Rendering, context, and inheritance
Make component dependencies explicit and remove assumptions inherited from Django template context.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-007 | Component template must be well-formed | A component's template is arbitrary text passed to the Django template engine; unclosed tags are tolerated | An unclosed or mismatched tag is an error: loading the component fails with SyntaxError: Unclosed tag <thead> | Close every tag in a component template. A partial that was a bare <thead> fragment has to become a complete unit, for example by including its <table> wrapper and passing the rows in as a slot. | 🟡 |
| DJC-008 | Ambient template context | A component can read variables that are simply in the surrounding Context, and exposes self.outer_context | A component receives only its explicit props (kwargs) and slots; there is no ambient context and no outer_context | Pass every value a component needs as an explicit prop. For caller state that must reach deep descendants, use provide / inject. | 🔴 |
| DJC-009 | context_behavior setting and only | context_behavior chooses django (child sees outer context) vs isolated, and only forces isolation per call | citry is always isolated, as if only were always on | Remove context_behavior from settings and drop only; behavior already matches djc's isolated. A project that ran in django mode must also rewrite fills that read the child's variables (loop items and friends): pass them explicitly as scoped slot data (c-name= on the slot site, data= on the fill). | 🟡 |
| DJC-010 | Request, context processors, CSRF | self.request, context-processor variables, and csrf_token are injected into the template context | citry injects no ambient request-derived variables | Read the request in your view and pass what each component needs (CSRF token, current user, locale) as ordinary props. There is no per-request ambient context, so a value many components need is best provided once near the top of the page with <c-provide> and read with inject(). Follow CSRF protection for Django and Citry Events. | 🔴 |
| DJC-011 | Slot-filled introspection | {% if component_vars.is_filled.title %} branches on whether a slot was filled | The component_vars.is_filled magic variable is gone | In template_data compute {'has_title': slots.get('title') is not None}, then branch with <c-if>. | 🟡 |
| DJC-013 | Observing which components rendered | The Django template_rendered signal and assertTemplateUsed report what rendered | citry has no template signal | Replace signal receivers / assertTemplateUsed checks with a test extension that records on_component_rendered. | 🟡 |
| DJC-014 | Django template inheritance | Component templates use {% extends %} / {% block %}, and {% include %} pulls in partials | citry has no template inheritance or {% include %} | Restructure an {% extends %} template into a base component composed via slots; replace {% include 'p.html' %} with a <c-p /> component. | 🔴 |
Template syntax and expressions
Citry templates use HTML-shaped component tags and Python-shaped expressions rather than Django template tags and filters.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-015 | Component invocation syntax | A pluggable TagFormatter / ShorthandComponentFormatter customizes the {% component %} tag form | citry's syntax is the fixed <c-*> form; there is no formatter to configure | Remove any tag_formatter setting and custom formatter subclasses; write components as <c-name />. | 🔴 |
| DJC-016 | django-template-partials integration | Rendering template.html#partial_name where the partial contains components | No direct equivalent | Compose the partial as a citry component and render it directly. | 🔴 |
| DJC-017 | Unterminated expression/comment delimiters | An opened {{ or {# with no closing delimiter falls back to visible text | Citry raises SyntaxError when the component is loaded, before anything renders | Close the expression/comment delimiter; do not rely on malformed template syntax rendering literally. | 🟡 |
| DJC-018 | Django template filters | Tag values use value\|filter:arg, filter registries, chaining, and filter-specific whitespace/arity rules | Citry has no template filters; \| inside an expression is Python bitwise-or | Rewrite filters as Python expressions, for example value.upper(), 'yes' if value else 'no', or an explicitly supplied helper callable. | 🔴 |
| DJC-019 | Translation shorthand in component inputs | _('text') is a special translation value inside arguments, filter arguments, lists, and dicts | Citry has no translation token/backend; the default sandbox also rejects the variable name _ | Translate in template_data, or expose a non-underscore callable such as translate through template globals and use c-label="translate('Hello')". | 🔴 |
| DJC-020 | Positional component inputs and list spreads | Tags accept positional values and ...list, with Python-like positional/keyword ordering rules | Component invocations are kwargs-only; c-bind spreads mappings, not positional lists | Give every input a name. Replace a positional list spread with a mapping and c-bind, or model the list as one named prop. | 🔴 |
| DJC-021 | Parser-registered tag flags | A TagSpec can declare flags that affect parsing but are omitted from the component's args/kwargs | There are no parser flags. A bare attribute is a normal input with the value True | Convert each custom flag into an explicit boolean prop and handle it in the component. | 🔴 |
| DJC-022 | $component callback payload | The first callback argument is the component's JS-data object, with a separate context argument | One object is passed: {id, els, data}. Extensions may add more members to it | Rewrite the django-components callback as $component(({data, els, id}) => { const {message} = data; ... }). Handle data === null when js_data() returns no values. | 🔴 |
| DJC-023 | Dependency placement tags | {% component_css_dependencies %} emits CSS only; {% component_js_dependencies %} emits JS only | <c-css /> and <c-js /> only choose where the styles or scripts go. Leave one out and those assets still land on the page, in their usual place | Use the tags for placement only. If you left {% component_css_dependencies %} out of a page to keep its CSS off, that no longer works. To keep an asset off the page, remove it from the component or filter it in on_dependencies. | 🟡 |
| DJC-024 | Component names containing / | The string-form component tag can address a registry name such as te-s/t | Component names are HTML-tag-compatible: they start with a letter and contain only letters, digits, hyphens, underscores, or dots | Rename a slash-delimited registry key, for example te-s/t to te-s-t or te.s.t, and update the <c-*> invocation. | 🔴 |
| DJC-025 | Alpine ownership and load order | Alpine is an external dependency; placing it before component JS can make an alpine:init listener miss the event | Citry Events loads and starts its own copy of Alpine. If the page has already loaded Alpine, citry leaves yours running and logs a warning | On pages that use Citry Events, delete your own Alpine <script> and stop ordering it against component JS; an alpine:init listener will no longer miss the event. Your existing x- attributes keep working untouched. | 🔴 |
| DJC-033 | Attribute evaluation (the biggest trap) | An attribute value is evaluated by the template engine, and {{ }} interpolates inside a quoted value | A plain attribute value is taken literally: only a c--prefixed attribute is evaluated, and {{ }} written into a static value renders literally (class="{{ kind }}" outputs class="{{ kind }}", with no error) | Add a c- prefix to any attribute whose value must be evaluated. key="hi" passes the string "hi"; c-key="hi" evaluates hi. Rewrite class="{{ x }}" as c-class="x". This one fails silently, so grep your templates for {{ inside an attribute. | 🔴 |
| DJC-037 | The {% %} tag language | Values and bodies may contain any registered block tag, for example {% lorem n w %} or a custom tag, including inside a component argument | There is no {% %} tag language. Text written that way is not executed; it renders to the page exactly as typed | Compute the value in Python and pass it as an expression attribute (c-flag="is_active"), or move the logic into template_data. Control flow is <c-if> / <c-for>. | 🔴 |
| DJC-039 | Mixed literal text plus expression in one value | bool_var=" {% noop is_active %} " yields the string " True ": stray whitespace silently turns a typed value into a string | A c- value is one expression, so you get exactly the type the expression returns. (A value holding a whole <c-*> tag is a nested component instead, see DJC-042) | Build the string yourself where you want one: c-label="f' {is_active} '". The accidental downgrade cannot happen. | 🟡 |
| DJC-040 | Template comment placement | {# #} works anywhere, including inside a component argument, where it collapses to "" | A comment can sit between tags or between attributes (<a {# note #} class="x">), but not inside an attribute value: in a plain attribute it renders as visible text, and in a c- attribute it is an error | Move every {# #} out of attribute values: put it before the attribute, or on its own line above the tag. title="{# note #}Hi" would ship the comment to the browser. | 🟡 |
| DJC-042 | Passing markup as an input | A whole {% component 'card' ... / %} written inside an argument renders to HTML, and that HTML becomes the outer input | A c- value that starts with an HTML tag and ends with its closing tag is a nested template rather than an expression: real markup, rendered with the same data, so {{ }} works inside it. It is the one place a c- value is not a Python expression | Write the markup straight into the value: c-body="<span>Hello {{ name }}</span>". Any HTML works, including several roots (<em>a</em><em>b</em>), a self-closing tag (<br/>), or a component (<c-badge c-label='name' />). Anything else is still an expression, so plain text needs quotes: c-body="'hello'". Write tags complete: a half-open tag is an error. A nested component renders after the outer one, so its finished HTML is not available inside the outer component's template_data. | 🟡 |
| DJC-043 | The same input given in both forms | No such concept; there is one argument syntax | Writing title="x" and c-title="y" on one tag is always a parse-time error because both explicitly provide the same logical input. Plain-element class/c-class and style/c-style are the accumulating exceptions. A c-bind spread may interlace with one explicit spelling because the key may be absent at render time. Repeating the same spelling twice is always an error | Pick one explicit form per input. Preserve intentional class/style accumulation on elements; move conditional overrides into c-bind. | 🟡 |
| DJC-054 | Authoring custom template tags | Subclass BaseNode (tag, end_tag, allowed_flags) or decorate a function with @template_tag; inputs follow the render function's Python signature | There is no tag-registration API. The one user-defined tag is a registered component: <c-my-tag /> looks the name up in the registry, and an unknown name fails at render naming the tag | Rewrite each custom tag as a component: the render function's body moves into template_data or on_render, its parameters become Kwargs fields, the tag body arrives as the default slot, and flags convert as in DJC-021. | 🔴 |
Slots, provide, and inject
Port composition together so slot data and values shared with descendants stay explicit.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-029 | Choosing the implicit/default slot | A default flag can mark an arbitrary named {% slot "main" default %} as the target of implicit component-body content | Implicit body content always fills the literal slot name default, rendered by a bare <c-slot /> (or <c-slot name="default" />) | Rename the receiving slot to default, or keep its name and wrap caller content in an explicit <c-fill name="main">. | 🔴 |
| DJC-030 | Missing template variables | An absent Django template variable renders as an empty string | A name that is not defined raises KeyError, pointing at the line and column where it is used, so a typo fails loudly instead of rendering an empty string | Supply every referenced name, guard the expression/branch, or compute an explicit default in template_data. | 🟡 |
| DJC-031 | Slot callbacks and forwarding existing Slots | SlotContext exposes a Django Context, fallback uses SlotFallback, and {% fill body=my_slot %} forwards a Slot | SlotContext exposes data, fallback: Slot \| None, and provides; there is no Django Context or body= shortcut | Remove callback reads from ctx.context, treat ctx.fallback as an ordinary optional Slot, and forward with <c-fill name="x">{{ my_slot }}</c-fill>. | 🔴 |
| DJC-032 | Legacy fill fallback alias | {% fill "x" default="fallback_var" %} remains as a deprecated alias | Only the explicit fallback="fallback_var" attribute is accepted | Rename default= to fallback= on every fill that binds the receiving slot's fallback. | 🔴 |
| DJC-034 | The {% provide %} tag | {% provide name key=val var:field=... %}...{% endprovide %}: a positional name, and var:field= colon-prefix aggregate kwargs | <c-provide key="name" ...>...</c-provide>: the name is the key attribute (c-key for a computed one). Each var:field= group becomes one attribute holding a dict | Rewrite the block as <c-provide> and move the positional name to key=. Turn each group into one dict attribute: {% provide "x" var1:key="hi" %} becomes <c-provide key="x" c-var1="{'key': 'hi'}">. | 🟡 |
| DJC-035 | Injected payload type | inject(...) returns a DepInject NamedTuple | inject(...) returns a Provided NamedTuple | Field access (payload.field) and tuple behaviour are unchanged; the only observable difference is the type name in the repr. Update any assertion or logging that matches the payload's type name or repr. | 🟡 |
| DJC-036 | provide / inject key errors | A missing/empty/invalid provide name raises TypeError / TemplateSyntaxError; a missing inject key raises KeyError | An invalid provide key raises ValueError. A missing inject key still raises KeyError, now with a suggestion of the closest key that was provided | Update except clauses and assertions that match the old exception types or message text. | 🟡 |
Assets and browser startup
Replace Django static-file assumptions and verify how each migrated page starts its browser behavior.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-004 | Dependency rendering strategy | render_dependencies(html, strategy=...) / DJC_DEPS_STRATEGY with strategies document/simple/prepend/append/raw and a legacy type= alias | One serialize(deps_strategy=..., deps_position=...) call: deps_strategy is document/simple/fragment/ignore, deps_position is smart/prepend/append | Call serialize() on the render result and pass the strategy there: MyComp(...).render().serialize(deps_strategy="document", deps_position="append"). Map prepend/append to deps_position, map raw to deps_strategy="ignore", and drop type=. A project-wide default goes on the Citry(...) instance rather than in settings. | 🟡 |
| DJC-012 | Static asset delivery | Component assets are served through ComponentsFileSystemFinder / collectstatic, gated by static_files_allowed / static_files_forbidden | citry serves only generated component scripts/styles through its own mounted WSGI/ASGI routes; component source (.py/.html) is never served | Remove the finder from STATICFILES_FINDERS, drop collectstatic for components and the static_files_* settings, and mount citry's asset routes. A custom media_class (overridden render_js/render_css) has no hook either: control tag output with Script/Style entries and the on_dependencies hooks. | 🔴 |
| DJC-045 | Order of inherited JS/CSS | A subclass's Media entries come before its parent's, so the parent's CSS wins equal-specificity ties | The parent's entries come first and the subclass's last, so the subclass's CSS wins the tie | Usually nothing: the new order is the one that lets a subclass override its parent's styles. If you relied on the parent winning, restate the parent's rule in the subclass. | 🟡 |
| DJC-046 | Order of classes named in extend | The listed classes' assets merge in reverse order | They merge in the order you wrote them (extend = [A, B] gives A's assets before B's) | Only matters when two listed classes ship conflicting styles: if you relied on the reversed order, reverse your list. | 🟡 |
| DJC-047 | bytes asset paths | A bytes path in Media is accepted | Raises TypeError naming the component and the offending value | Decode bytes paths to str (or use a pathlib.Path). The error tells you exactly which component and entry to fix. | 🟡 |
| DJC-074 | Delivery of js_data() values to the browser | The script carrying get_js_data() values is generated, cached, and shipped whenever the component has any JS at all, even a plain script that never reads the data | The js_data() script reaches the page only when the component's JS registers a $component callback. With a plain script the data is never shipped, and nothing warns. (css_data() is unaffected: its stylesheet ships whenever the component has CSS) | JS that consumes js_data() values must read them inside a $component callback (DJC-022 shows the shape). After porting, check every component that pairs a plain script with js_data(): either convert the script to the callback form or delete the unused js_data(). If a test only asserted the data script's presence, drop that assertion for plain-script components. | 🟡 |
| DJC-085 | Browser dependency-manager namespace | The runtime exposes DjangoComponents, the legacy Components alias, createComponentsManager(), and registerComponentData(..., factory) | One load-safe singleton lives at globalThis.Citry.manager; there are no django-components aliases or manager factory, and registerComponentData takes the data value itself | Replace both old globals with Citry.manager, delete calls that construct private managers, and pass the JS-data object rather than a factory when registering data manually. | 🔴 |
| DJC-086 | Component initialization completion and failures | callComponent() returns a Promise for the callback's synchronous or asynchronous result; callback errors reject it | callComponent() is synchronous fire-and-forget and returns undefined. A returned function is the instance cleanup; other values are ignored. Returned Promises are unsupported, and synchronous throws or Promise rejections are logged and isolated so later initialization continues | Move asynchronous server work to an Events handler and await the client sendEvent() promise. Keep $component initialization synchronous, return only an optional cleanup function, and do not await callComponent() or use callback return values as results. | 🔴 |
| DJC-087 | Component initialization with no DOM roots | A component call rejects when no element carries its instance marker | The callback still runs with els=[]; rootless components and temporarily absent roots are valid lifecycle states | Handle an empty els array when initialization needs a root. Do not use rejection from callComponent() as a missing-root signal. | 🟡 |
Extensions and lifecycle hooks
Move extension behavior to Citry lifecycle hooks, routes, configuration, and commands.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-050 | Render lifecycle hooks | Three hooks: on_render_before, on_render (with a lambda-yield protocol), on_render_after | One hook: on_render(self). Return content (or None to keep the template), or write it as a generator: code before yield runs before the template renders, the yield receives the finished result, and code after it can inspect or replace the output | Merge the three bodies into one on_render: the before-hook code goes before the yield, the after-hook code after it. Each yield content replaces the output and receives the new result; errors arrive at the same yield. Note the yield hands back a render object, not a string: to append to the output, return str(result) + "...". Code that added template variables in on_render_before moves into template_data. | 🔴 |
| DJC-051 | Inputs named with a leading @ | @lol=2 arrives in the component's kwargs like any other input | An @-prefixed attribute with a string value is a client-side event instruction (for the events layer); it never reaches the component's inputs, and nothing warns you. A bare @-flag or a non-string value fails loudly with a TypeError naming the attribute | Rename data inputs that start with @ (for example at_lol or on_lol). Audit templates for @-prefixed attributes that were meant as data, not events. | 🔴 |
| DJC-052 | The render marker attribute | Each rendered root carries data-djc-id-<id> | Each rendered root carries data-cid-<id>="" (a fresh id per render) | Update CSS selectors, JS lookups, and snapshot assertions that match data-djc-id-*. | 🟡 |
| DJC-055 | Registry lifecycle extension hooks | on_registry_created / on_registry_deleted fire when a standalone registry is constructed or collected | There is no standalone registry: it is part of each Citry engine, and no such hooks exist | Observe engine creation with on_extension_created (its context carries the engine); registry-deletion logic has nothing to attach to, since per-engine state dies with the engine. | 🟡 |
| DJC-056 | on_component_rendered when a render fails | Fires once on the failing component itself, with the error message wrapped in the components-path prefix | The failing component's own hook does not fire; each enclosing component's hook fires as the error bubbles, receiving the original exception | Move per-component error handling (logging, boundaries) to an ancestor's hook or wrap the component; match the original exception, not the wrapped djc string. | 🟡 |
| DJC-057 | Extension URL routes | Auto-served by Django under /components/ext/<name>/, Django path syntax with typed converters (<int:id> hands the handler an int) | A framework-neutral route table the host app mounts (via a citry.contrib adapter) under <prefix>/ext/<name>/; params are {name} segments, always captured as strings | Rewrite <int:id> as {id} and convert inside the handler (int(id)); return a RouteResponse instead of an HttpResponse; mount Citry.urls in the host app. | 🔴 |
| DJC-058 | Declaring an extension's per-component config | A nested class named ComponentConfig (legacy alias ExtensionClass still accepted) | A Config class attribute subclassing Extension.Config; there is no legacy alias | Rename ComponentConfig (or ExtensionClass) to Config and its base to Extension.Config; update hook bodies for the renamed context fields (ctx.component_class and friends). | 🔴 |
| DJC-059 | Reading hook-processed assets | Component.template / .js / .css return content with the loaded-hooks applied | The class attributes keep exactly what you wrote; the hook-processed, cached content comes from get_template().source / get_js() / get_css() | Switch introspection and tests that read the class attributes expecting processed content to the accessor methods. | 🟡 |
| DJC-061 | Dropping a component class at runtime | Classes are not registered at definition, and the file index tracks them weakly, so an unregistered class dies with your last reference | Defining a class registers it, and the engine holds it strongly: call engine.unregister(cls) before dropping the last reference; render caches then release it normally | Unregister classes you replace at runtime (hot-swap tooling, plugin unload), then drop your own references. A fully rendered class is collectable and its weak file-index entry is pruned. | 🟡 |
| DJC-084 | Extensions: authoring CLI commands | An extension declares CLI commands as ComponentCommand subclasses; the command's handle receives Django's global options (settings, pythonpath, skip_checks, ...) and underscore-prefixed parser internals in its kwargs, which authors had to pop out | The same declarative shape lives on citry's ExtensionCommand (imported from citry): name, help, arguments built from CommandArg/CommandArgGroup, nested subcommands, and a handle(**kwargs). handle receives only the options the command tree declares, nothing needs popping, and the engine the CLI resolved is available as self.citry. Users run it as citry ext run <extension> <command> | Rebase command classes onto citry's ExtensionCommand and update the imports (CommandArg/CommandArgGroup keep their argparse-matching fields). Delete any code that pops parser internals or reads Django global options from kwargs; reach the engine through self.citry instead of Django settings. | 🔴 |
| DJC-090 | Component-class deletion extension hook | on_component_class_deleted(ctx) receives OnComponentClassDeletedContext from a class finalizer | Citry exposes neither the hook nor the context because Python can run finalizers while arbitrary application locks are held | Move explicit-removal cleanup to on_component_unregistered. Use weak containers for memory-only indexes that should disappear with an unregistered class. Citry.clear() is a bulk teardown and emits no per-component hooks. | 🔴 |
Setup, discovery, and command line
Connect tooling to the same Citry instance used by the application and remove Django-owned discovery settings.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-026 | Registry ownership and discovery | Standalone registries, @register(..., registry=...), and global all_registries() support custom scopes and process-wide enumeration | Each Citry instance owns its registry; classes declare citry = app or use app.register(...), and there is no global registry inventory | Create and retain a Citry instance for each component scope. Replace decorators with class assignment or app.register, and retain any registry/app references your own tooling needs instead of calling all_registries(). | 🔴 |
| DJC-027 | Settings scope and component directories | Django's global COMPONENTS accepts a dict or ComponentsSettings; absent dirs defaults to BASE_DIR/components | Settings are typed and per Citry instance; component directories are explicit absolute dirs and no Django BASE_DIR is consulted | Move COMPONENTS values into Citry(...) arguments or CitrySettings(...). Pass every component directory explicitly as an absolute path. | 🔴 |
| DJC-028 | Parser-reserved component names | Protected names follow django-components' registered Django tags and selected formatter | if, elif, else, for, empty, raw, fill, and slot are citry's own tag names, so no component can use them | Rename a colliding component and update its <c-*> uses; for example, rename Empty to EmptyState. | 🔴 |
| DJC-060 | Turning on hot reload | The reload_on_file_change setting (True/False/"hot"/"restart"/"off") | An explicit call: enable_hot_reload(engine, mode="hot") (or "restart"); nothing watches until you call it, and there is no off value | Delete the setting; call enable_hot_reload where your dev server starts. An invalid mode fails at the call, not at settings load. | 🟡 |
| DJC-064 | The libraries setting | COMPONENTS["libraries"] lists module paths that import_libraries() loads at startup | No such setting or helper; component modules are found by scanning Citry(dirs=...) or by ordinary imports | Delete the libraries entry: move those modules under a scanned directory, or import them plainly where your app starts. | 🔴 |
| DJC-065 | Running autodiscovery | The module-level autodiscover(map_module=...) function, anchored to Django apps | An instance method: app.autodiscover() (or the default lazy scan on first lookup); no map_module hook; paths anchor to sys.path | Call autodiscover() on your Citry instance or rely on the lazy default; delete map_module usage. | 🟡 |
| DJC-066 | The @djc_test testing harness | Wraps tests to reset djc's process-global state (registries, caches, sys.modules snapshots) | No harness ships. Each Citry instance owns its registry and caches; the one process-wide piece of state is the default instance, which components fall back to when they do not set citry= | Remove @djc_test; create a fresh Citry() per test and pass it to the components under test (citry = c) instead of relying on the default instance. | 🟡 |
| DJC-077 | Reserved component names (built-in tags) | Two built-in names are taken at startup, dynamic (or your configured rename) and error_fallback; registering another component under either raises AlreadyRegistered | The built-in tag names component, element, provide, cache, error-fallback, js, and css are all reserved. Because a component class auto-registers under its lowercased class name, a class simply named Element fails at class definition with AlreadyRegistered naming the built-in it collides with | Rename a colliding class (for example Element to ElementView) or give it an explicit non-reserved name attribute, then update its <c-*> uses. Row #28 lists the parser tag names reserved for the same reason; this row adds the built-in component names. | 🔴 |
| DJC-080 | Reading mapping and slot-data keys with a dot in expressions | The Django template dot resolves dict keys too: {{ data.error }} shows the error entry of a dict, and slot data is habitually read that way | Expressions use Python attribute access. Fill data is Citry's immutable SlotData, so identifier keys support {{ d.error }}; unusual keys and names colliding with mapping methods use brackets or fill destructuring. Ordinary dict values still require subscripts. | Keep dot access for identifier-like slot-data keys. Rewrite dot access only when the value is an ordinary dict, or use brackets/destructuring for an unusual slot-data key such as aria-label. Dot access on real object attributes is unchanged. | 🟡 |
| DJC-081 | Running component commands | Component commands run through Django: python manage.py components create\|upgrade\|ext\|list, carrying Django's global options (--settings, --pythonpath, --traceback, --no-color, --skip-checks, -v) | Installing citry puts a standalone citry command on your PATH: citry list, citry inspect [component] --json, citry create <name>, citry watch, citry ext list, citry ext run <extension> <command>, plus --version. inspect --json emits the successfully loaded engine's versioned runtime component catalog; the optional case-insensitive name or alias keeps the same catalog envelope with one component. Neither form has a static-analysis fallback. There is no manage.py integration and Django's global options do not exist; a project that builds its own Citry instance points the CLI at it with a leading --app module:attribute (the same convention ASGI/WSGI servers use). The upgrade and startcomponent commands do not exist: upgrade migrated legacy Django-template syntax that citry does not use, and startcomponent was djc's deprecated alias of create | Replace every manage.py components ... invocation in scripts, docs, and CI with the citry binary; add --app your.module:engine as the first argument if your project constructs its own engine. Remove Django global options from those invocations. Anything that ran upgrade has nothing left to migrate; replace startcomponent X with citry create X (DJC-082). | 🔴 |
| DJC-082 | The create scaffold | components create X scaffolds a directory X/ with template.html, script.js, style.css (and X.py), customizable via --js/--css/--template, previewable with --dry-run, overwritable with --force, chatty with --verbose | citry create MyButton writes a single my_button.py containing the component class with an inline multiline template (no separate HTML/JS/CSS files), takes only --path, always prints the created file path, and refuses to touch an existing file; there is no --force, --dry-run, --js/--css/--template, or --verbose | Expect one Python file per scaffold instead of a directory of assets, and drop the removed flags from any wrapper scripts (they now fail with a usage error). To redo a scaffold, delete the file first; the command will never overwrite it for you. | 🟡 |
| DJC-083 | Listing output and its flags | components list prints full_name and path columns (dotted class path plus source file), and list / ext list accept --all, --columns, and --simple to add columns, pick columns, or drop the header row | citry list prints one row per component: all its registered names (the lowercased and kebab-case forms share the row), the class name, and the file defining the component (relative to the working directory when inside it; a component with no source file leaves the cell empty); citry ext list prints the extension names. The columns are fixed and there are no --all/--columns/--simple flags (passing one is a usage error) | Update anything that parses the listing output to the new fixed columns; strip the formatting flags from saved invocations (they now fail with a usage error). | 🟡 |
Component APIs, dynamic rendering, Events, and caching
Finish the Python-facing API changes, then verify dynamic components, HTTP behavior, and cached output.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-062 | Choosing a component by a variable | {% component name_var %} is rejected with "Component name must be a string 'literal', got: ...", steering you to other patterns | A tag name is always literal (<c-{{ name }} will not interpolate); the dynamic path is the built-in dynamic component: <c-component c-is="name_var" /> | Rewrite variable-name calls as <c-component c-is="..." />. | 🔴 |
| DJC-067 | Data-method names and signatures | Template/JS/CSS data come from get_template_data(self, args, kwargs, slots, context) (and get_js_data, get_css_data) | The methods are template_data(self, kwargs, slots), js_data(self, kwargs, slots), css_data(self, kwargs, slots). A ported component that still defines get_template_data renders without any error, but the method is never called: the template sees only the raw kwargs, so the output is silently wrong. | Rename the three methods and drop the args and context parameters (values read from the Django context become explicit props, provide/inject, or template_globals; see #8 and #20 for those parameters). After porting, grep the project for def get_template_data, def get_js_data, def get_css_data: any hit is a dead method. | 🔴 |
| DJC-068 | What a bare typed-input class becomes | A bare inner Kwargs/Slots/TemplateData (etc.) class is rebuilt as a NamedTuple: instances are tuples, so kwargs[0], a, b = kwargs, iteration, and _asdict()/_replace() all work | The same class is rebuilt as a dataclass with fixed attributes: attribute access works, tuple behavior does not (indexing, unpacking, and _asdict() raise), and setting an undeclared attribute on an instance also raises | Replace tuple-style access on typed instances with attribute access (or self.raw_kwargs for a plain dict). Classes declared with an explicit base (NamedTuple, @dataclass, pydantic model) are left untouched by both frameworks, so those need no change. | 🟡 |
| DJC-069 | Declaring a no-inputs component | Kwargs = Empty (imported from django-components) declares the component takes no inputs; violations raise TypeError at render | There is no Empty type (the import itself fails). The same contract is an empty class Kwargs: pass: a template attribute then fails at parse ("can only have the following attributes ..."), and a Python-call kwarg raises TypeError at render | Replace Kwargs = Empty with class Kwargs: pass, and delete Args = Empty entirely (components are kwargs-only, DJC-020). | 🟡 |
| DJC-070 | Files with dots in their names in component dirs | Dot-prefixed files and directories are silently skipped during discovery, but other dotted names (a card.old.py backup, an assets.v2/ directory) crash the scan | Any file or directory with a dot in its name (beyond the .py suffix) is silently skipped: dot-prefixed junk (.#card.py editor locks, ._card.py macOS copies, .cache/ trees), backup copies like card.old.py, directories with a dotted name (hiding their whole subtree), and symlinks resolving to such paths | Files that crashed djc's scan are now skipped, and every regular file djc discovered is still discovered. One exception: a clean-named symlink pointing at a file inside a dot-prefixed directory was imported by djc but is skipped by citry; point the symlink at a dot-free path or replace it with the real file. If a skipped file should be discovered, rename it to a plain dot-free name. | 🟡 |
| DJC-071 | Declaring default input values | Defaults live in a separate inner Defaults class; Default(...) wraps a factory for mutable values; get_component_defaults(MyComponent) reads the resolved defaults | Defaults are ordinary field defaults on the declared Kwargs class (size: int = 10); a factory is dataclasses.field(default_factory=...); there is no defaults-reading helper. An inner class still named Defaults is silently ignored: nothing errors, the defaults just stop applying (a template reading the value fails with a missing-name error; if a Kwargs class is declared, passing the input is rejected as unexpected, and without one the input is simply accepted untyped) | Move each Defaults attribute onto the Kwargs class as an annotated field: variable = "test" becomes variable: str = "test". The annotation is required, an unannotated name = value declares nothing. Rewrite Default(fn) as field(default_factory=fn), and a mutable default like items = [] as field(default_factory=list) (writing items: list = [] fails at class definition with "mutable default ... use default_factory"). There is no direct replacement for get_component_defaults(...). If the caller only inspects declarations, iterate dataclasses.fields(MyComp.Kwargs) and handle dataclasses.MISSING while reading each field's default or default_factory. If it needs resolved per-instance values, read them from the typed kwargs instance during rendering, or add an application helper that explicitly invokes factories. Then delete the Defaults class: leaving it behind fails silently. | 🔴 |
| DJC-072 | Passing None to get the default | An input explicitly given as None still receives its declared default (None is treated as "missing") | None is a value like any other: the default applies only when the input is omitted, so a template that used to show the default now shows None | Omit the input where you meant "use the default". If a caller may legitimately hold None, resolve it yourself in template_data (value if value is not None else fallback). This changes output silently, so audit call sites that pass None on purpose. | 🟡 |
| DJC-073 | Defaults in the raw kwargs dict | self.raw_kwargs includes the defaults for inputs the caller omitted | self.raw_kwargs holds exactly what the caller passed; defaults appear only on the typed kwargs (the kwargs argument of template_data). Reading an omitted input from the raw dict raises KeyError | Read defaulted inputs through the typed kwargs (kwargs.size), not the raw dict. Where code iterates self.raw_kwargs expecting the complete set of inputs, switch it to the typed instance. | 🟡 |
| DJC-075 | Where processed component JS/CSS is cached | Processed JS/CSS is written to the Django cache named by the components cache setting (a private in-memory cache of its own when unset, never Django's default cache), under __components:... keys, as soon as the component class is defined | Each Citry instance writes to its own pluggable cache (Citry(cache=...), a per-instance in-memory store by default), under citry:... keys, when the component first renders | Multi-worker setups that shared processed assets through a configured Django cache must pass a shared store to Citry(cache=...); ready adapters exist for the Django cache framework (citry.contrib.django.DjangoCache), Redis, and diskcache (citry.contrib.caches). Update monitoring or warm-up jobs that looked for __components:* keys or expected the cache to fill at import time: keys start with citry: and appear at first render. | 🟡 |
| DJC-076 | Dynamic components | The dynamic component is a Python class you can import and render directly (DynamicComponent.render(kwargs={"is": ...})), registered under the tag name dynamic, and renameable with the dynamic_component_name setting | The dynamic component is the fixed built-in <c-component> tag. There is no importable wrapper class and no rename setting; the tag name cannot be changed | Rewrite {% component "dynamic" is=x %} (and any renamed shorthand) as <c-component c-is="x" /> (DJC-062 shows the invocation shape). Delete the dynamic_component_name setting. In Python, drop the DynamicComponent import and resolve the target yourself: app.get(name)(**kwargs) when you hold a name, or call the component class you already hold. | 🔴 |
| DJC-079 | The built-in error boundary component | {% component "error_fallback" %} with the guarded content in a content slot (also fillable as default) and the fallback as a fallback slot or kwarg; the ErrorFallback class is importable; giving the fallback as both slot and kwarg raises TemplateSyntaxError | <c-error-fallback>: the guarded content is the tag body; the fallback is the fallback="..." attribute, or a fallback fill that receives the error as slot data (the guarded content then goes in the default fill, since fills cannot mix with other content). There is no importable class. A leftover <c-fill name="content"> fails on the component's first render, with a parse error naming the fill (the class itself defines without error). Giving both fallback forms raises RuntimeError ("give only one") | Rewrite the invocation as <c-error-fallback> with the guarded content directly in the body. When you use the fallback fill, rename the content fill to default and read the error as d.error. Delete ErrorFallback imports; Python-side, call app.get("error-fallback")(fallback="...", slots={"default": ...}). Update any except clause or test that matched TemplateSyntaxError or the old both-forms message. | 🔴 |
| DJC-088 | Component HTTP handlers and their self | Component.as_view() dispatches get / post either from Component.View or directly from the component, and the handler can use a live component instance plus render_to_response(context=..., slots=...) | Put verb-shaped handlers in class Events(ViewEvents):. Their inputs are typed data and the neutral request; self is the per-call Events config, not a rendered component. Return a fresh component element or an Events action | Move each direct or nested view handler under Events(ViewEvents), replace host request parsing with a data class, and replace render_to_response with a returned component or action. Move values formerly read from the live component into explicit data, context, State, or application services. | 🔴 |
| DJC-089 | Component endpoint URLs and exposure | get_component_url() builds one optional public URL per component, public=False disables it, and get_route_path() plus args / kwargs define custom paths | Public methods placed in Events are exposed on fixed routes. Named handlers use events.url(name, query=..., fragment=...) or get_event_url(...); there is no public flag or custom per-component route reversal, and the method-only ViewEvents route has no dedicated public builder | Replace public flags with handler placement or omission. Use named handlers and the event URL builders for durable call sites; keep query and fragment inputs, but move route parameters into typed event data. Treat ViewEvents as the initial method-shaped bridge, not a custom routing API. | 🔴 |
| DJC-091 | Selecting a render-cache backend per component | Component.Cache.cache_name selects one named Django cache backend | A Citry instance owns one cache backend; component and fragment output caching use it | Pass the intended shared or local backend once as Citry(cache=...). Split components across Citry instances only when they truly require different engine ownership; there is no per-component backend alias in V1. | 🟡 |
| DJC-092 | Component cache key customization and Slots | Cache.hash() can replace key generation, while include_slots attempts to add Slot values automatically | Cache.vary(self, kwargs, slots) returns typed semantic variation and Citry owns canonical hashing. Every content-producing Slot requires an explicit variation; Citry never guesses from closures or source text | Replace hash() and include_slots with a vary() result containing only the values that can change output, including explicit Slot-presence or caller-controlled dimensions where relevant. | 🔴 |
| DJC-093 | Template fragment cache syntax | Django's {% cache timeout key *vary_on using=... %}...{% endcache %} can run in a standalone Django template | Citry uses the transparent component <c-cache key="..." c-ttl="..." c-vary="...">...</c-cache> inside a component template, with the engine-owned backend | Move standalone cached markup into a root component template, translate timeout and variation to typed attributes, and remove {% load cache %} / using=. | 🔴 |
| DJC-094 | IDs inside cached rendered output | Django's fragment cache reuses frozen rendered HTML, including the original component ID | Citry caches a detached artifact and mints fresh descendant IDs on every replay while reusing only the current boundary ID | Do not persist or compare a descendant data-cid-* across renders. Bind browser state to the current render; Citry remaps ownership, dependency, and Events records to those fresh IDs. | 🟡 |
Update exact-output tests
These differences do not change the page a browser presents, but error assertions and exact HTML snapshots may need updating.
| ID | Area | django-components | Citry | What to change | Impact |
|---|---|---|---|---|---|
| DJC-003 | Single-quote HTML escaping | Escaped as ' | Escaped as ' (the same character, numeric-decimal entity) | Nothing for rendered pages; browsers treat the two entities identically. Update only tests that assert the literal ' bytes. | 🟢 |
| DJC-005 | Error when a component's inline JS/CSS contains its own end tag | Raises RuntimeError, message ...contains '</script>' end tag. | Raises ValueError, message ...contains a '</script>' end tag. This is not allowed. | If you catch this error or assert its message, switch to ValueError and the new wording. | 🟢 |
| DJC-006 | The citry runtime script | A dependency-manager script is emitted on every document render | Citry adds its runtime script (citry.js) only when a page needs it: when a component uses $component, or when the page must stay in step with HTML fragments loaded later | Nothing changes in your templates. In tests, drop assertions that the runtime <script> is present on every rendered document. | 🟢 |
| DJC-038 | Parentheses around expressions | Python-expression mode is opt-in per value: disabled=(not editable) | A c- value is always an expression, so the parentheses are not what makes it one. Keeping them still works | Nothing has to change. When tidying, drop them: c-disabled="not editable". What matters is the c- prefix, not the parentheses. | 🟢 |
| DJC-041 | Builtins in expressions | Helpers such as len are commonly added to the render context per call | Python builtins are not available inside expressions: len(...), str(...) and friends raise NameError unless you supply the name yourself | Not a behavior change, but there is a better home for them: register helpers once with Citry(template_globals={"len": len}) instead of passing them on every render. | 🟢 |
| DJC-044 | Assets on a plain definition class | A non-component base class carrying a Media class contributes its entries | Reusable definition bases and plain classes named in extend contribute preserved Dependencies; relative paths resolve from the declaring module and files are registered to the consuming component | Keep reusable assets on the definition that owns them. Use Dependencies = None, extend = False, or an explicit extend list to cut or select branches. | 🟢 |
| DJC-048 | Declaring one member of an asset pair as None | Setting js = ... while js_file = None (or the reverse) raises | Legal: only two values that are both set conflict; the set member is used | Nothing required. If you deleted an explicit = None to satisfy djc, you can put it back. | 🟢 |
| DJC-049 | Protocol-relative asset URLs (://example.com/x.js) | The emitted tag escapes the leading colon (href="%3A//example.com/...") | The entry is emitted exactly as written | Nothing for rendered pages. Update only tests that assert the escaped %3A// bytes. | 🟢 |
| DJC-053 | Error paths for components placed via a fill | The error trace includes a slot segment for content rendered through a slot | Content failing inside a fill or fallback still shows a slot segment, as Card(slot:body) (djc wrote provider(slot:content)). Only a component placed via a fill loses the frame: its path is the authorship chain alone (Page > Failing) | Update slot-marker assertions to the Card(slot:body) spelling, and drop the slot expectation only for component-failure paths. | 🟢 |
| DJC-063 | Text next to explicit fills | Text or variables beside {% fill %} tags raise TemplateSyntaxError when fills are used | Same protection, different reporter: the parent's first render raises SyntaxError, worded "Text cannot appear next to '<c-fill>'" for literal text and "Expression cannot appear..." for a variable | Update assertions that match the djc error type or message. | 🟢 |
| DJC-078 | Unknown component name error message | Rendering an unknown component name raises NotRegistered with the message "The component 'x' was not found" | Still NotRegistered, now worded "No component registered as 'x'." When it is <c-component> that cannot resolve the name, the message additionally suggests using <c-element> for a plain HTML element | Update except clauses and test assertions that match the old wording; the exception class name is unchanged. Nothing changes for code that only catches the exception type. | 🟢 |
Migrate Component.View
If the project defines Component.View, finish the template and component port first, then follow Migrate from Component.View. It shows how to keep a verb-shaped route working before splitting it into named, typed Citry Events.
Verify the migration
Before removing django-components:
- Search again for its component, slot, fill, provide, dependency, and cache tags, plus its settings and registry imports.
- Run the original unit and snapshot tests, updating exact HTML assertions only where the checklist identifies an output-only difference.
- Exercise browser initialization, Alpine behavior, component assets, forms, CSRF protection, fragments, and event endpoints used by the migrated pages.
- Check that production serves Citry's generated asset routes and that no old component source directory is exposed as a static directory.
- Run
citry listandcitry inspect --jsonagainst the sameCitryinstance the application serves.
Give this migration to a coding agent
Use the Markdown version of this page so the agent receives the checklist without navigation or presentation markup. Start with an audit, review its plan, and only then ask it to edit the project.
Read this project's AGENTS.md, README, dependency files, and test commands.
Detect the installed Citry and django-components versions before choosing
documentation. Read https://citry.dev/llms.txt and use its version selector to
find the matching Markdown version of the "Migrate from django-components"
guide. If matching Citry documentation is unavailable, report the mismatch
instead of applying instructions from another version.
Audit this project for every DJC-### item in the migration guide. For each
applicable item, report the matching file paths, the required rewrite, and its
risk. Mark every other item not applicable or blocked. Include the existing
Python, browser, and snapshot commands that should verify the work.
Do not edit files yet. Produce a staged plan that migrates one connected group
of components at a time and keeps the application runnable between stages. Do
not add django-components compatibility shims.
After approving the audit, tell the agent which stage to implement. Require it to report the DJC-### items addressed and the verification results before it continues to the next stage.