In the previous post, we explored the high-level architecture of release.new. Today, we'll dive deep into one of its core features: the reactive preview that updates in real-time as users adjust their preferences. This two-way binding between form options and the rendered output creates a seamless experience where users can fine-tune their release notes with immediate feedback.
The Heart of Reactivity
At the core of our implementation is a Livewire component that manages the state of various formatting options. Each option is bound to the preview through Livewire's built-in lifecycle hooks. When the user changes an option, Livewire sends a call to the backend with the updated property state. Through the Livewire listeners, we can hook into these events and reformat the release notes and make actions as needed.
In the past, both Jason and I have talked about prefetching. This is also triggered using Livewire hooks. As soon as a clone URL is updated, we trigger a prefetch.
public function updated(string $name, $value): void{ // ... if ($name === 'form.clone_url') { $this->prefetch(); return; }}
As another example, a newly added feature is "undos", so each time a field is updated we store the previous state of the form.
public function updating(string $name, $value){ if ($this->releaseNotes && $this->editedMarkdown) { $this->storeUndoState($name); }}
Entangling the Form with the Preview
The real magic happens in how we connect these options to the preview. Instead of handling each option change individually, which would lead to a hard-to-understand black box, we instead opted to simply regenerate the release notes when any option is updated. Thankfully the way we designed the object-oriented generator class, it's easy to regenerate the release notes when any option is updated. We simply hydrate the object instance with the current option state and regenerate the release notes.
class ReleaseNotesObject implements Wireable{ public function __construct(CommitRange $range) { $this->range = $range; } public function generate(ChangelogOptions $options): string { $document = "## Release Notes\n\n"; // Magic happens here }}
We call this from our main Livewire component. When any option is modified, it triggers a regeneration of the release notes for the current state. Then we update the preview and Markdown, as well as any availability flags. This approach provides a consistent and predictable way to handle updates.
Smart State Management
One challenge we encountered was managing the interdependencies between options. Some options only make sense when others are enabled, or need to trigger background jobs when activated. We solved this through a combination of reactive properties and computed states:
private function updateAvailabilityFlags(): void{ // If there are no simple changes in the document, we disable the option $this->hasSimpleChanges = $this->releaseNotes->hasSimpleChanges(); // If we have found first contributors, and we have contributors, we enable the option $this->hasFirstContributors = $this->foundFirstContributors && $this->findContributors()->isNotEmpty(); // If there is no new version, we disable the option $this->showFullReleaseNotesLink = ! $this->form->new_version;}
The updateAvailabilityFlags
method ensures our UI stays consistent by managing these interdependencies. For example, if there are no simple changes to hide, we automatically disable that option to prevent confusion.
The Preview Component
The preview itself needed to be both performant and responsive. Swapping between the panes needed to be instant, and the Markdown needed to be reactive. We did this using a combination of Livewire and Alpine.js.
The actual contents of the preview and Markdown are updated by Livewire as soon as the output is changed on the backend, and we hydrate both of these at the same time. Then we use Alpine.js to switch visibility of the tabs.
<article x-show="activeTab === 'html'"> {{ $compiledHtml }}</article><div x-show="activeTab === 'markdown'"> <textarea wire:model.blur="form.markdown"></textarea></div>
This implementation allows users to seamlessly switch between viewing the formatted output and editing the raw Markdown. Changes in either view are automatically synchronized through Livewire's two-way binding system.
Handling Manual Edits
We also wanted to support manual editing of the generated Markdown. This required special handling to preserve user changes while still allowing option toggles. This is where the undo
feature comes in. I already showed you how we store the previous state of the form when an option is updated, and here is how we handle the undo:
When we are in an undoable state, we add a flag in Livewire, this then opens up a "toast" notification in the UI that allows the user to undo the change. If the user hits the undo button, we call the undo
method on the Livewire component which restores the previous state of the form and updates the preview and Markdown.
public function undo(): void{ if ($this->canUndo) { $this->restorePreviousState(); $this->clearUndoState(); $this->regenerate(); $this->canUndo = false; }}
The undo
system provides a way to revert individual changes, making it safe for users to experiment with different options without losing their manual edits. This creates a forgiving interface that encourages exploration.
The Output Generator
As mentioned before, we have a dedicated class handles the actual generation of the release notes based on the current options, called ReleaseNotesObject
.
This takes in all the inputs and assembles the output based on it. While this class controls which lines are added, and where, we also have each line represented by an object which allows us to further control how we assemble each line. This also takes in the options, and allows us to customize the output based on the options.
For example, here is the method we use to assemble a line:
private function generateLine(ChangelogOptions $options): string{ $line = '* '; $line .= $this->commit->title; if ($options->showAuthor) { $line .= ' by '.$this->getFormattedAuthorLink(); } if ($this->commit->isPullRequest()) { $line .= ' in ['.$this->commit->pr_number.']('.$this->getPullRequestUrl().')'; } if ($this->commit->isSimple()) { $line .= ' in ['.$this->commit->hash.']('.$this->getCommitUrl().')'; } return trim($line);}
This object-oriented approach to generating the output makes it easy to extend with new features and ensures consistent formatting across all generated notes. It also makes it easy to unit test all various cases which is especially important when dealing with logic that can mutate in many ways.
Having two options means we have 4 possible states, with three options we have 8 possible states. If we ever add a fourth option, we would have 16 possible states. So this unit-testable approach means it's easy to ensure all of these are correct, without having to set up the entire system to test each state.
What's Next?
Make sure to check back on the blog soon for my next post. In the meantime, why not try out these reactive features yourself at release.new?
The immediate feedback loop created by this reactive preview system has proven invaluable for us trying to perfect the release notes. It transforms what could be a tedious process of trial and error into a smooth, interactive experience.
Syntax highlighting by Torchlight.dev