Theme
Version
GitHub PyPI Discord
On this page

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:

  1. Create a branch and run the existing Python, browser, and snapshot tests.
  2. Inventory component directories, settings, custom template tags, extensions, JavaScript hooks, caches, and Component.View subclasses.
  3. Install Citry, create one Citry instance, connect it to Django, and register or discover one leaf component.
  4. Port that component and its tests. Verify it in the browser before moving to the next connected group.
  5. 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-001Merging HTML attributesThe {% html_attrs %} tag (positional args, attrs: / defaults: aggregate keys, spread)Element-level attributes: c-bind="mapping" to spread, plus c-class and c-styleRewrite {% 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-002Repeated non-class/style attribute keysThe 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-007Component template must be well-formedA component's template is arbitrary text passed to the Django template engine; unclosed tags are toleratedAn 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-008Ambient template contextA component can read variables that are simply in the surrounding Context, and exposes self.outer_contextA component receives only its explicit props (kwargs) and slots; there is no ambient context and no outer_contextPass every value a component needs as an explicit prop. For caller state that must reach deep descendants, use provide / inject.🔴
DJC-009context_behavior setting and onlycontext_behavior chooses django (child sees outer context) vs isolated, and only forces isolation per callcitry is always isolated, as if only were always onRemove 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-010Request, context processors, CSRFself.request, context-processor variables, and csrf_token are injected into the template contextcitry injects no ambient request-derived variablesRead 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-011Slot-filled introspection{% if component_vars.is_filled.title %} branches on whether a slot was filledThe component_vars.is_filled magic variable is goneIn template_data compute {'has_title': slots.get('title') is not None}, then branch with <c-if>.🟡
DJC-013Observing which components renderedThe Django template_rendered signal and assertTemplateUsed report what renderedcitry has no template signalReplace signal receivers / assertTemplateUsed checks with a test extension that records on_component_rendered.🟡
DJC-014Django template inheritanceComponent templates use {% extends %} / {% block %}, and {% include %} pulls in partialscitry 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-015Component invocation syntaxA pluggable TagFormatter / ShorthandComponentFormatter customizes the {% component %} tag formcitry's syntax is the fixed <c-*> form; there is no formatter to configureRemove any tag_formatter setting and custom formatter subclasses; write components as <c-name />.🔴
DJC-016django-template-partials integrationRendering template.html#partial_name where the partial contains componentsNo direct equivalentCompose the partial as a citry component and render it directly.🔴
DJC-017Unterminated expression/comment delimitersAn opened {{ or {# with no closing delimiter falls back to visible textCitry raises SyntaxError when the component is loaded, before anything rendersClose the expression/comment delimiter; do not rely on malformed template syntax rendering literally.🟡
DJC-018Django template filtersTag values use value\|filter:arg, filter registries, chaining, and filter-specific whitespace/arity rulesCitry has no template filters; \| inside an expression is Python bitwise-orRewrite filters as Python expressions, for example value.upper(), 'yes' if value else 'no', or an explicitly supplied helper callable.🔴
DJC-019Translation shorthand in component inputs_('text') is a special translation value inside arguments, filter arguments, lists, and dictsCitry 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-020Positional component inputs and list spreadsTags accept positional values and ...list, with Python-like positional/keyword ordering rulesComponent invocations are kwargs-only; c-bind spreads mappings, not positional listsGive every input a name. Replace a positional list spread with a mapping and c-bind, or model the list as one named prop.🔴
DJC-021Parser-registered tag flagsA TagSpec can declare flags that affect parsing but are omitted from the component's args/kwargsThere are no parser flags. A bare attribute is a normal input with the value TrueConvert each custom flag into an explicit boolean prop and handle it in the component.🔴
DJC-022$component callback payloadThe first callback argument is the component's JS-data object, with a separate context argumentOne object is passed: {id, els, data}. Extensions may add more members to itRewrite the django-components callback as $component(({data, els, id}) => { const {message} = data; ... }). Handle data === null when js_data() returns no values.🔴
DJC-023Dependency 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 placeUse 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-024Component names containing /The string-form component tag can address a registry name such as te-s/tComponent names are HTML-tag-compatible: they start with a letter and contain only letters, digits, hyphens, underscores, or dotsRename a slash-delimited registry key, for example te-s/t to te-s-t or te.s.t, and update the <c-*> invocation.🔴
DJC-025Alpine ownership and load orderAlpine is an external dependency; placing it before component JS can make an alpine:init listener miss the eventCitry Events loads and starts its own copy of Alpine. If the page has already loaded Alpine, citry leaves yours running and logs a warningOn 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-033Attribute evaluation (the biggest trap)An attribute value is evaluated by the template engine, and {{ }} interpolates inside a quoted valueA 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-037The {% %} tag languageValues and bodies may contain any registered block tag, for example {% lorem n w %} or a custom tag, including inside a component argumentThere is no {% %} tag language. Text written that way is not executed; it renders to the page exactly as typedCompute 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-039Mixed literal text plus expression in one valuebool_var=" {% noop is_active %} " yields the string " True ": stray whitespace silently turns a typed value into a stringA 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-040Template 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 errorMove 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-042Passing markup as an inputA whole {% component 'card' ... / %} written inside an argument renders to HTML, and that HTML becomes the outer inputA 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 expressionWrite 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-043The same input given in both formsNo such concept; there is one argument syntaxWriting 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 errorPick one explicit form per input. Preserve intentional class/style accumulation on elements; move conditional overrides into c-bind.🟡
DJC-054Authoring custom template tagsSubclass BaseNode (tag, end_tag, allowed_flags) or decorate a function with @template_tag; inputs follow the render function's Python signatureThere 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 tagRewrite 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-029Choosing the implicit/default slotA default flag can mark an arbitrary named {% slot "main" default %} as the target of implicit component-body contentImplicit 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-030Missing template variablesAn absent Django template variable renders as an empty stringA 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 stringSupply every referenced name, guard the expression/branch, or compute an explicit default in template_data.🟡
DJC-031Slot callbacks and forwarding existing SlotsSlotContext exposes a Django Context, fallback uses SlotFallback, and {% fill body=my_slot %} forwards a SlotSlotContext exposes data, fallback: Slot \| None, and provides; there is no Django Context or body= shortcutRemove 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-032Legacy fill fallback alias{% fill "x" default="fallback_var" %} remains as a deprecated aliasOnly the explicit fallback="fallback_var" attribute is acceptedRename default= to fallback= on every fill that binds the receiving slot's fallback.🔴
DJC-034The {% 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 dictRewrite 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-035Injected payload typeinject(...) returns a DepInject NamedTupleinject(...) returns a Provided NamedTupleField 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-036provide / inject key errorsA missing/empty/invalid provide name raises TypeError / TemplateSyntaxError; a missing inject key raises KeyErrorAn invalid provide key raises ValueError. A missing inject key still raises KeyError, now with a suggestion of the closest key that was providedUpdate 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-004Dependency rendering strategyrender_dependencies(html, strategy=...) / DJC_DEPS_STRATEGY with strategies document/simple/prepend/append/raw and a legacy type= aliasOne serialize(deps_strategy=..., deps_position=...) call: deps_strategy is document/simple/fragment/ignore, deps_position is smart/prepend/appendCall 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-012Static asset deliveryComponent assets are served through ComponentsFileSystemFinder / collectstatic, gated by static_files_allowed / static_files_forbiddencitry serves only generated component scripts/styles through its own mounted WSGI/ASGI routes; component source (.py/.html) is never servedRemove 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-045Order of inherited JS/CSSA subclass's Media entries come before its parent's, so the parent's CSS wins equal-specificity tiesThe parent's entries come first and the subclass's last, so the subclass's CSS wins the tieUsually 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-046Order of classes named in extendThe listed classes' assets merge in reverse orderThey 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-047bytes asset pathsA bytes path in Media is acceptedRaises TypeError naming the component and the offending valueDecode bytes paths to str (or use a pathlib.Path). The error tells you exactly which component and entry to fix.🟡
DJC-074Delivery of js_data() values to the browserThe 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 dataThe 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-085Browser dependency-manager namespaceThe 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 itselfReplace 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-086Component initialization completion and failurescallComponent() returns a Promise for the callback's synchronous or asynchronous result; callback errors reject itcallComponent() 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 continuesMove 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-087Component initialization with no DOM rootsA component call rejects when no element carries its instance markerThe callback still runs with els=[]; rootless components and temporarily absent roots are valid lifecycle statesHandle 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-050Render lifecycle hooksThree hooks: on_render_before, on_render (with a lambda-yield protocol), on_render_afterOne 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 outputMerge 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-051Inputs named with a leading @@lol=2 arrives in the component's kwargs like any other inputAn @-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 attributeRename 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-052The render marker attributeEach 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-055Registry lifecycle extension hookson_registry_created / on_registry_deleted fire when a standalone registry is constructed or collectedThere is no standalone registry: it is part of each Citry engine, and no such hooks existObserve 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-056on_component_rendered when a render failsFires once on the failing component itself, with the error message wrapped in the components-path prefixThe failing component's own hook does not fire; each enclosing component's hook fires as the error bubbles, receiving the original exceptionMove 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-057Extension URL routesAuto-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 stringsRewrite <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-058Declaring an extension's per-component configA nested class named ComponentConfig (legacy alias ExtensionClass still accepted)A Config class attribute subclassing Extension.Config; there is no legacy aliasRename 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-059Reading hook-processed assetsComponent.template / .js / .css return content with the loaded-hooks appliedThe 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-061Dropping a component class at runtimeClasses are not registered at definition, and the file index tracks them weakly, so an unregistered class dies with your last referenceDefining a class registers it, and the engine holds it strongly: call engine.unregister(cls) before dropping the last reference; render caches then release it normallyUnregister 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-084Extensions: authoring CLI commandsAn 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 outThe 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-090Component-class deletion extension hookon_component_class_deleted(ctx) receives OnComponentClassDeletedContext from a class finalizerCitry exposes neither the hook nor the context because Python can run finalizers while arbitrary application locks are heldMove 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-026Registry ownership and discoveryStandalone registries, @register(..., registry=...), and global all_registries() support custom scopes and process-wide enumerationEach Citry instance owns its registry; classes declare citry = app or use app.register(...), and there is no global registry inventoryCreate 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-027Settings scope and component directoriesDjango's global COMPONENTS accepts a dict or ComponentsSettings; absent dirs defaults to BASE_DIR/componentsSettings are typed and per Citry instance; component directories are explicit absolute dirs and no Django BASE_DIR is consultedMove COMPONENTS values into Citry(...) arguments or CitrySettings(...). Pass every component directory explicitly as an absolute path.🔴
DJC-028Parser-reserved component namesProtected names follow django-components' registered Django tags and selected formatterif, elif, else, for, empty, raw, fill, and slot are citry's own tag names, so no component can use themRename a colliding component and update its <c-*> uses; for example, rename Empty to EmptyState.🔴
DJC-060Turning on hot reloadThe 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 valueDelete the setting; call enable_hot_reload where your dev server starts. An invalid mode fails at the call, not at settings load.🟡
DJC-064The libraries settingCOMPONENTS["libraries"] lists module paths that import_libraries() loads at startupNo such setting or helper; component modules are found by scanning Citry(dirs=...) or by ordinary importsDelete the libraries entry: move those modules under a scanned directory, or import them plainly where your app starts.🔴
DJC-065Running autodiscoveryThe module-level autodiscover(map_module=...) function, anchored to Django appsAn instance method: app.autodiscover() (or the default lazy scan on first lookup); no map_module hook; paths anchor to sys.pathCall autodiscover() on your Citry instance or rely on the lazy default; delete map_module usage.🟡
DJC-066The @djc_test testing harnessWraps 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-077Reserved 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 AlreadyRegisteredThe 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 withRename 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-080Reading mapping and slot-data keys with a dot in expressionsThe Django template dot resolves dict keys too: {{ data.error }} shows the error entry of a dict, and slot data is habitually read that wayExpressions 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-081Running component commandsComponent 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 createReplace 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-082The create scaffoldcomponents 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 --verbosecitry 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 --verboseExpect 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-083Listing output and its flagscomponents 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 rowcitry 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-062Choosing a component by a variable{% component name_var %} is rejected with "Component name must be a string 'literal', got: ...", steering you to other patternsA 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-067Data-method names and signaturesTemplate/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-068What a bare typed-input class becomesA 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 workThe 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 raisesReplace 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-069Declaring a no-inputs componentKwargs = Empty (imported from django-components) declares the component takes no inputs; violations raise TypeError at renderThere 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 renderReplace Kwargs = Empty with class Kwargs: pass, and delete Args = Empty entirely (components are kwargs-only, DJC-020).🟡
DJC-070Files with dots in their names in component dirsDot-prefixed files and directories are silently skipped during discovery, but other dotted names (a card.old.py backup, an assets.v2/ directory) crash the scanAny 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 pathsFiles 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-071Declaring default input valuesDefaults live in a separate inner Defaults class; Default(...) wraps a factory for mutable values; get_component_defaults(MyComponent) reads the resolved defaultsDefaults 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-072Passing None to get the defaultAn 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 NoneOmit 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-073Defaults in the raw kwargs dictself.raw_kwargs includes the defaults for inputs the caller omittedself.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 KeyErrorRead 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-075Where processed component JS/CSS is cachedProcessed 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 definedEach 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 rendersMulti-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-076Dynamic componentsThe 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 settingThe 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 changedRewrite {% 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-079The 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-088Component HTTP handlers and their selfComponent.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 actionMove 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-089Component endpoint URLs and exposureget_component_url() builds one optional public URL per component, public=False disables it, and get_route_path() plus args / kwargs define custom pathsPublic 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 builderReplace 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-091Selecting a render-cache backend per componentComponent.Cache.cache_name selects one named Django cache backendA Citry instance owns one cache backend; component and fragment output caching use itPass 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-092Component cache key customization and SlotsCache.hash() can replace key generation, while include_slots attempts to add Slot values automaticallyCache.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 textReplace 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-093Template fragment cache syntaxDjango's {% cache timeout key *vary_on using=... %}...{% endcache %} can run in a standalone Django templateCitry uses the transparent component <c-cache key="..." c-ttl="..." c-vary="...">...</c-cache> inside a component template, with the engine-owned backendMove standalone cached markup into a root component template, translate timeout and variation to typed attributes, and remove {% load cache %} / using=.🔴
DJC-094IDs inside cached rendered outputDjango's fragment cache reuses frozen rendered HTML, including the original component IDCitry caches a detached artifact and mints fresh descendant IDs on every replay while reusing only the current boundary IDDo 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.

IDAreadjango-componentsCitryWhat to changeImpact
DJC-003Single-quote HTML escapingEscaped 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-005Error when a component's inline JS/CSS contains its own end tagRaises 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-006The citry runtime scriptA dependency-manager script is emitted on every document renderCitry 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 laterNothing changes in your templates. In tests, drop assertions that the runtime <script> is present on every rendered document.🟢
DJC-038Parentheses around expressionsPython-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 worksNothing has to change. When tidying, drop them: c-disabled="not editable". What matters is the c- prefix, not the parentheses.🟢
DJC-041Builtins in expressionsHelpers such as len are commonly added to the render context per callPython builtins are not available inside expressions: len(...), str(...) and friends raise NameError unless you supply the name yourselfNot 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-044Assets on a plain definition classA non-component base class carrying a Media class contributes its entriesReusable 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 componentKeep reusable assets on the definition that owns them. Use Dependencies = None, extend = False, or an explicit extend list to cut or select branches.🟢
DJC-048Declaring one member of an asset pair as NoneSetting js = ... while js_file = None (or the reverse) raisesLegal: only two values that are both set conflict; the set member is usedNothing required. If you deleted an explicit = None to satisfy djc, you can put it back.🟢
DJC-049Protocol-relative asset URLs (://example.com/x.js)The emitted tag escapes the leading colon (href="%3A//example.com/...")The entry is emitted exactly as writtenNothing for rendered pages. Update only tests that assert the escaped %3A// bytes.🟢
DJC-053Error paths for components placed via a fillThe error trace includes a slot segment for content rendered through a slotContent 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-063Text next to explicit fillsText or variables beside {% fill %} tags raise TemplateSyntaxError when fills are usedSame 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 variableUpdate assertions that match the djc error type or message.🟢
DJC-078Unknown component name error messageRendering 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 &lt;c-component&gt; that cannot resolve the name, the message additionally suggests using &lt;c-element&gt; for a plain HTML elementUpdate 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 list and citry inspect --json against the same Citry instance 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.