# Structured Concurrency in Java 25 URL: https://chornonoh-vova.com/blog/structured-concurrency-java-25/ Date: 2026-08-06 Imagine you're building a dashboard for an e-commerce application. When a user opens the page, they expect to see everything at once: - their profile - recent orders - unread notifications - personalized recommendations Nothing extraordinary. Just another REST endpoint. ```http GET /dashboard ``` But, in our imaginary application, these pieces of information don't live in the same place, each of them is in a separate service, so our API needs to call them before returning the response. The easiest implementation is the most obvious one: 1. Call each service 2. Wait for the response 3. Move to the next service ```java @GetMapping("/dashboard") public DashboardDto getDashboard() { log.info("Starting dashboard request"); DashboardDto dashboard = new DashboardDto(); dashboard.setProfile(profileService.getProfile()); dashboard.setOrders(ordersService.getOrdersList()); dashboard.setNotifications(notificationsService.getNotificationsList()); dashboard.setRecommendations(recommendationsService.getRecommendationsList()); log.info("Finishing dashboard request"); return dashboard; } ```
A note on the services Each of the four services looks the same way: ```java @Slf4j @Service public class NotificationsService { private final RestClient restClient = RestClient.create(); public List getNotificationsList() { log.info("Starting notifications request"); List notifications = restClient.get() .uri("http://notifications-service/notifications") .retrieve() .body(new ParameterizedTypeReference<>() {}); log.info("Finishing notifications request"); return notifications; } } ``` The other three differ only in the name and the endpoint. In the demo I ran to collect the output below, each of the services calls a local endpoint that just sleeps for a fixed number of milliseconds rather than a real service, so the timings stay reproducible without standing up four actual services. > That `log` is coming from Lombok's `@Slf4j` annotation, and each of the four services is written the same way — one log line on the way in, one on the way out.
Here's a small visualization of what is going on — expand **Show logs** underneath it to see what the application prints while those bars fill: Each service is requested sequentially, therefore the duration of this API request is the sum of durations of all of the calls to other services. Ok, it's not ideal, but we can make the endpoint perform better by parallelizing each of those calls! ```java @GetMapping("/dashboard") public DashboardDto getDashboard() throws InterruptedException, ExecutionException { log.info("Starting dashboard request"); CompletableFuture profileFuture = CompletableFuture.supplyAsync(() -> profileService.getProfile()); CompletableFuture> ordersFuture = CompletableFuture.supplyAsync(() -> ordersService.getOrdersList()); CompletableFuture> notificationsFuture = CompletableFuture.supplyAsync(() -> notificationsService.getNotificationsList()); CompletableFuture> recommendationsFuture = CompletableFuture.supplyAsync(() -> recommendationsService.getRecommendationsList()); CompletableFuture.allOf( profileFuture, ordersFuture, notificationsFuture, recommendationsFuture ).join(); DashboardDto dashboard = new DashboardDto(); dashboard.setProfile(profileFuture.get()); dashboard.setOrders(ordersFuture.get()); dashboard.setNotifications(notificationsFuture.get()); dashboard.setRecommendations(recommendationsFuture.get()); log.info("Finishing dashboard request"); return dashboard; } ``` In this example, we start all of the futures asynchronously and execute them in parallel, then we wait for all of the results in `allOf(...).join()`. There's one detail here that comes back later. `supplyAsync` without a second argument runs the task on `ForkJoinPool.commonPool()`. That pool is sized for CPU work, one thread fewer than your core count, and it's shared with every parallel stream and every other `CompletableFuture` in the JVM. We've just given it four tasks that do nothing but wait on a socket. Here's what it looks like, visualized: In this case, the total duration of the execution will be the maximum duration of one of the service requests (in this case, recommendations service). 890ms down to 310ms (almost 3x speedup!), and we barely touched the code. ## What happens when one of them fails Start with the sequential version, where the answer is boring. That's the point: it's the baseline that parallelism quietly takes away. Notifications service throws, and that's the end of it. Recommendations service is never called, because the line that would have called it never runs. There's no cleanup to think about and nothing left over: the stack unwinds and takes the whole request with it. Let's take a look now at the same failure but in the parallel version: Look at how much later the exception reaches the log than the failure that caused it. `allOf(...).join()` waits for *every* future, and it has no notion of one of them having already made the result worthless. So orders and recommendations carry on to their natural end, log their "Finishing" lines into a request that is already dead, and only then does the `CompletionException` surface. The numbers are right there in the diagram. We know about the failure at 170ms, the caller hears about it at 310ms, and 230ms of thread time goes into two results that nobody reads. On a single request that's just untidy. At a few hundred requests a second, that's the common pool spending its threads on answers that are already garbage instead of on requests that could still succeed. The obvious fix is to cancel the futures we don't need anymore. It doesn't work. The javadoc for `CompletableFuture.cancel` is blunt about why: > `mayInterruptIfRunning` — this value has no effect in this implementation > because interrupts are not used to control processing. And the javadoc isn't overstating it. Decompile `cancel` and the boolean parameter is never read at all: the method completes the future with a `CancellationException` and returns. `cancel(true)` gives you back `true`, `isCancelled()` starts answering `true`, and the thread underneath keeps running to the end. We cancelled our view of the result. The work carries on. So this is what we actually want: ## Doing it by hand Getting that behaviour isn't a matter of adding a few `cancel` calls to what we already have. `CompletableFuture` can't express it at all. Interruption only works through `Future.cancel` on a real `ExecutorService`, so we have to put `CompletableFuture` down and pick up a different set of APIs: `ExecutorService`, `Future` and `ExecutorCompletionService`. That version looks like this: ```java @GetMapping("/dashboard") @SuppressWarnings("unchecked") public DashboardDto getDashboard() throws InterruptedException, ExecutionException { log.info("Starting dashboard request"); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var completion = new ExecutorCompletionService(executor); Future profileFuture = completion.submit(() -> profileService.getProfile()); Future ordersFuture = completion.submit(() -> ordersService.getOrdersList()); Future notificationsFuture = completion.submit(() -> notificationsService.getNotificationsList()); Future recommendationsFuture = completion.submit(() -> recommendationsService.getRecommendationsList()); var all = List.of(profileFuture, ordersFuture, notificationsFuture, recommendationsFuture); for (int i = 0; i < all.size(); i++) { try { completion.take().get(); } catch (ExecutionException exception) { for (Future sibling : all) { sibling.cancel(true); } throw exception; } } DashboardDto dashboard = new DashboardDto(); dashboard.setProfile((ProfileDto) profileFuture.get()); dashboard.setOrders((List) ordersFuture.get()); dashboard.setNotifications((List) notificationsFuture.get()); dashboard.setRecommendations((List) recommendationsFuture.get()); log.info("Finishing dashboard request"); return dashboard; } } ``` Three things that make it work: - `ExecutorCompletionService` instead of joining futures in order. `take()` blocks until some task finishes, so you find out about the failure at the moment it fails. Joining `profileFuture`, then `ordersFuture`, then others gets you the failure only after everything ahead of it in your hand-written order has finished. - `Future.cancel(true)` on an `ExecutorService` task does set the interrupt, unlike `CompletableFuture.cancel(true)`. - `try`-with-resources on the executor. `close()` shuts down and waits, so the method can't return or throw while a subtask is still alive. But have you noticed how the complexity suddenly exploded? I had to research every one of those three points to get this right, and none of them is visible in the code. Neither are the ways it can still bite: - `ExecutorCompletionService` plus casts. It's built for homogeneous task sets, so four different return types means `Object` on the way out and a cast on the way back in. Three of those four casts are unchecked, which is what the `@SuppressWarnings` on the method is quietly hiding. - The loop counter is load-bearing. Nothing prevents us from calling `take()` three times instead of four, and if we do, the fourth task runs on past the end of the request with nothing waiting for it and nothing to report it. - Cancellation still only works if the task is interruptible. Blocking I/O on a virtual thread is, which is why cancellation works here. But if a subtask ignores interrupts, `close()` waits on it *indefinitely*. - There's no timeout anywhere in this, and adding one means a third mechanism bolted alongside the two already here. There's one more idea worth mentioning, because it's the one I reached for first: let each task cancel its siblings when it fails, instead of doing it from the waiting thread. For that, every task needs a reference to the list of futures, and that list doesn't exist until the last `submit` returns. So the first task can start and fail while the array it's supposed to read is still half empty. To fix it properly you need a latch that parks every task until the list is published. So now there's a race in there too, and races are much harder to spot in review than tedium. So I asked myself a question: can I do this in a simpler way? ## The same thing, structured Yes, and that's what structured concurrency is for. It arrived in its current shape in Java 25, the latest LTS. ```java @GetMapping("/dashboard") public DashboardDto getDashboard() throws InterruptedException { log.info("Starting dashboard request"); try (var scope = StructuredTaskScope.open()) { Subtask profileTask = scope.fork(() -> profileService.getProfile()); Subtask> ordersTask = scope.fork(() -> ordersService.getOrdersList()); Subtask> notificationsTask = scope.fork(() -> notificationsService.getNotificationsList()); Subtask> recommendationsTask = scope.fork(() -> recommendationsService.getRecommendationsList()); scope.join(); DashboardDto dashboard = new DashboardDto(); dashboard.setProfile(profileTask.get()); dashboard.setOrders(ordersTask.get()); dashboard.setNotifications(notificationsTask.get()); dashboard.setRecommendations(recommendationsTask.get()); log.info("Finishing dashboard request"); return dashboard; } } ``` That gives us exactly [the timeline from the end of the last section](#parallel-execution-with-cancel). Same 170ms, same two interrupted siblings, same 230ms saved. The difference is all the code we didn't have to write. The no-argument `StructuredTaskScope.open()` uses the `Joiner.awaitAllSuccessfulOrThrow()` policy: it waits for every subtask, and the moment one of them fails it interrupts the threads running the others, then makes `join()` throw a `FailedException` wrapping the original error. Subtasks run on virtual threads by default, so there's no executor to configure. And coming back to that detail from the beginning, there's no `ForkJoinPool` shared with the rest of the application to starve. Notice what's *missing* compared to both of the earlier versions. There's no `ExecutionException` in the signature, because `FailedException` is unchecked. There's no completion queue, no loop counter, no cast, no `@SuppressWarnings`. And the waste from two diagrams ago isn't something you have to remember to avoid: interrupting the siblings is the `Joiner`'s job rather than yours, and `close()` won't let the `try` block exit while a subtask is still alive. Where `CompletableFuture` gave you no way to stop the other three, the scope gives you no way *not* to. One caveat carries over from the manual version: cancellation is still cooperative. The scope interrupts the sibling threads, but a subtask that never reaches an interruptible point keeps going until it does. The scope guarantees that nothing escapes the block. It can't guarantee that everything inside it stops on command. Blocking I/O on a virtual thread is interruptible, which is why this works for our four HTTP calls. The price is that this is still a [preview API](https://openjdk.org/jeps/505), so you need a flag to compile and run any of it: ```groovy tasks.withType(JavaCompile).configureEach { options.compilerArgs << "--enable-preview" } bootRun { jvmArgs = ["--enable-preview"] } test { jvmArgs = ["--enable-preview"] } ``` If running through IntelliJ IDEA, you'll need to add `--enable-preview` to the VM options of the run configuration. And be aware that classes compiled with preview features refuse to load on any other JVM version, so you can't compile on 25 and run on 26. At this point, the dashboard problem is solved. The remaining sections examine the API you will need beyond this example. ## How the scope works The dashboard only needed `open()` and `join()`. The rest of this section is what you'll want once your code stops looking like the example. ### Why "structured" The name comes from structured programming. Before `if` and `while` and blocks, control flow was `goto`: a jump could land anywhere, so you couldn't read a region of code and know what would run. Blocks fixed that by giving every region one entry and one exit. Concurrency is still in the `goto` era. `supplyAsync` starts work that outlives the line that started it, on a thread with no relationship to the one that asked for it. That's exactly why our two orphaned calls could keep logging into a request that was already dead. A `StructuredTaskScope` puts the block back: subtasks get forked inside it, `join()` is the one place they're waited for, and `close()` can't finish while any of them is still alive. And the JVM enforces this rather than trusting you to follow it. `fork`, `join` and `close` all have to be called by the thread that opened the scope. Scopes also nest: a subtask that opens its own scope becomes a branch of a tree the JVM knows about, which is what makes these threads readable in a thread dump. ### Joiners decide the policy The `Joiner` answers two questions: what are we waiting for, and what happens when one of these fails? Java 25 ships five.
| `Joiner` | `join()` returns | When a subtask fails | | ------------------------------ | -------------------- | --------------------------------------------- | | `awaitAllSuccessfulOrThrow()` | `Void` | cancels the rest, throws `FailedException` | | `awaitAll()` | `Void` | nothing — you inspect each `Subtask` yourself | | `allSuccessfulOrThrow()` | `Stream>` | cancels the rest, throws `FailedException` | | `anySuccessfulResultOrThrow()` | `T` | waits for another; throws only if all fail | | `allUntil(Predicate)` | `Stream>` | up to your predicate |
The no-argument `open()` is shorthand for the first one. The interesting one for a dashboard is the fourth, because it inverts the policy. First useful answer wins, and the losers get cancelled: ```java try (var scope = StructuredTaskScope.open( Joiner.anySuccessfulResultOrThrow())) { scope.fork(() -> primaryPricing.getPrice(sku)); scope.fork(() -> fallbackPricing.getPrice(sku)); // Returns the first of the two to succeed, and interrupts the other. return scope.join(); } ``` Note that `join()`'s return type changes with the joiner. That's the second type parameter on `StructuredTaskScope`: `T` is what the subtasks produce, `R` is what `join()` hands back. ### `Subtask` has three states, and `get()` respects them `Subtask.get()` is not `Future.get()`. It never blocks, and instead of a checked exception it throws `IllegalStateException` when there's no result to give: ```java try (var scope = StructuredTaskScope.open(Joiner.awaitAll())) { Subtask> notificationsTask = scope.fork(() -> notificationsService.getNotificationsList()); scope.join(); DashboardDto dashboard = new DashboardDto(); if (notificationsTask.state() == Subtask.State.SUCCESS) { dashboard.setNotifications(notificationsTask.get()); } else { log.warn("notifications unavailable", notificationsTask.exception()); dashboard.setNotifications(List.of()); } } ``` The states are `UNAVAILABLE` before `join()` returns (and permanently, for a subtask that got cancelled), then `SUCCESS` or `FAILED`. Calling `get()` on anything but `SUCCESS` throws, and so does calling it before `join()`. This one is easy to miss, because with `awaitAllSuccessfulOrThrow()` you never hit it. That joiner guarantees every subtask succeeded by the time `join()` returns, so `get()` is always safe there. Switch to `awaitAll()` because you want partial results, and suddenly it isn't. It's also genuinely useful. `awaitAll()` plus `state()` is how you say "the dashboard should still render if recommendations are down", which none of the all-or-nothing versions earlier could express. ### Timeouts and names The second `open()` overload takes a configuration function, and that's where the timeout the manual version never had comes from: ```java try (var scope = StructuredTaskScope.open( Joiner.awaitAllSuccessfulOrThrow(), config -> config.withName("dashboard").withTimeout(Duration.ofSeconds(2)))) { ``` When the deadline passes, the scope is cancelled the same way a failure cancels it, and `join()` throws `StructuredTaskScope.TimeoutException`, unchecked like `FailedException`. `withName` is what labels the scope in thread dumps, and `withThreadFactory` is there for the times you need subtasks on platform threads rather than virtual ones. ## Should you use it yet? Structured concurrency is still a preview API, which means it can change between releases, and it has. [JEP 505](https://openjdk.org/jeps/505) in Java 25 is the redesign that replaced the old `ShutdownOnFailure` subclasses with the `Joiner` policies used above, so most tutorials written before it won't compile at all. Java 26's [JEP 525](https://openjdk.org/jeps/525) is the sixth preview and only polishes the edges: `allSuccessfulOrThrow()` returns a `List` instead of a `Stream`, `anySuccessfulResultOrThrow()` was renamed to `anySuccessfulOrThrow()`, and `Joiner` gained an `onTimeout()` hook. Of everything above, only the joiner table and that two-way pricing example would need touching. The dashboard compiles unchanged on both. [JEP 533](https://openjdk.org/jeps/533) is done and lands in Java 27 as the seventh preview, not the finalization — seven rounds in, Java 28 is the earliest that could change. This one reaches further into the code above. The three throwing joiners make `join()` throw a plain `ExecutionException` instead of `FailedException`, and each gains an overload taking a `Function` to supply your own exception instead. A timeout arrives the same way now, an `ExecutionException` caused by `CancelledByTimeoutException` rather than its own `TimeoutException`, though `config.withTimeout(...)` itself is untouched. `Joiner.onTimeout()`, new in Java 26, is already replaced by `timeout()`. And `awaitAll()` is gone outright, which takes the partial-results dashboard with it; `allUntil(subtask -> false)` is the closest thing left. So, my answer: not in code you can't recompile on demand. The shape of the API has held steady since JEP 505 and what's left is renames, but preview still means the flag, and the flag pins your class files to one JVM version. The idea is worth taking either way, and that part doesn't need a flag. Go back to the sequential version at the top. Its real advantage was never readability. A failure there had nowhere to go but up, and nothing was left running by the time it got there. We gave that up the moment we typed `supplyAsync`, and everything in the middle of this article was us buying it back by hand: a completion queue to notice the failure, a loop to cancel the siblings, a `close()` so nothing outlived the method. With a scope, none of that is our job anymore. Concurrent work has the same lifetime as the block it's written in, and the runtime keeps it there. We get the guarantees of the sequential version and the speed of the parallel one, and the diagram with two calls still computing answers for a dead request becomes something you'd have to work at to write. > All of the examples are available in a [demo repository](https://github.com/chornonoh-vova/concurrency-demo), easily runnable with Gradle. --- # Re-architecting an old service: Part 2 URL: https://chornonoh-vova.com/blog/re-architecting-an-old-service-part-2/ Date: 2026-06-05 > This post is part 2 of the miniseries. Read part 1 [here](/blog/re-architecting-an-old-service-part-1). Porting [BrowserUp Proxy](https://github.com/browserup/browserup-proxy)'s feature set to [mitmproxy](https://github.com/mitmproxy/mitmproxy) meant porting more than a dozen distinct behaviors. Header injection. Basic and NTLM authentication. PDF handling. Cookie management. Content filtering. Debug capture. Each had edge cases, tests, and a reference implementation in Java on one side, with new Python addons needed on the other. Done serially, this was many weeks of work. I used Claude Code to implement most of the feature ports in parallel, and it worked. Not because of anything special about the AI. It worked because I put real time into the structure before writing any code. Get the structure wrong and the AI produces code that looks plausible, compiles, and does the wrong thing. Get it right and you get implementations you can actually ship. ## Designing for delegation AI agents do well on tasks that are self-contained and precisely scoped, with a runnable definition of done. They struggle with judgment calls: things no spec captures, decisions that constrain everything else. So the planning question I kept coming back to was: which pieces can I describe precisely enough that an agent can implement them without me answering follow-up questions? The answer shaped the PR structure. Two foundation PRs established the shared infrastructure: the interface, the factory, the controller, the session config schema, the addon loading pattern. These two had to land first, because every subsequent PR depended on the pattern it established. After they were merged, roughly a dozen feature PRs could run in any order. Each one touched a single Python addon file, its own fields in the shared config record, and its own tests. No feature PR overlapped with any other feature PR's addon. ```txt ┌───────────────────────────────────────┐ │ Foundation PR #1 │ │ interface · factory · controller │ └──────────────────┬────────────────────┘ ▼ ┌───────────────────────────────────────┐ │ Foundation PR #2 │ │ session config schema · addon loader │ └──────────────────┬────────────────────┘ ┌──────────┬─────────────┼─────────────┬──────────┐ ▼ ▼ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Feature │ │ Feature │ │ Feature │ │ Feature │ │ ... │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┴─────────────┼─────────────┴──────────┘ ▼ ┌─────────────────┐ │ Dockerization │ └─────────────────┘ ``` The foundation PRs are where I spent the most upfront effort. Everything downstream is parallel, but only if the foundation is clear enough to follow consistently. I wrote it myself, documented the pattern explicitly, and treated it as something an agent could use as a reference. If it had been ambiguous, every feature PR would have inherited that ambiguity. ## Three things that made it work ### 1. Written specs per feature Before handing off any feature PR, I wrote a short brief: what the addon does, which fields it reads from the session config, what the edge cases are, what the original Java filter does in each branch with notes on where mitmdump's behavior differs. Not a design doc. Just enough that the target was unambiguous. Vague specs produced drifting implementations that needed correction. Precise specs produced first passes I could iterate from directly. Basic authentication is a good example. The Java filter from BrowserUp hooks into LittleProxy's Netty pipeline via `clientToProxyRequest`: ```java @Override public HttpResponse clientToProxyRequest(HttpObject httpObject) { if (!(httpObject instanceof HttpRequest req)) return null; String host = req.headers().get(HttpHeaderNames.HOST, ""); if (!hostPattern.matcher(host).find()) return null; String encoded = Base64.getEncoder() .encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); req.headers().set(HttpHeaderNames.AUTHORIZATION, "Basic " + encoded); return null; } ``` The spec for the mitmproxy port translated that logic directly: on each request, check the host against the configured pattern, inject the header if it matches. The addon that came back: ```python from mitmproxy import http from config import SessionConfig class BasicAuthAddon: def __init__(self): self.config: SessionConfig | None = None self._encoded: str | None = None def configure(self, updated): self.config = SessionConfig.load() if self.config.basic_auth: auth = self.config.basic_auth self._encoded = base64.b64encode( f"{auth.username}:{auth.password}".encode() ).decode() else: self._encoded = None def request(self, flow: http.HTTPFlow) -> None: if not self._encoded: return if self.config.basic_auth.host_pattern and not re.search( self.config.basic_auth.host_pattern, flow.request.pretty_host ): return flow.request.headers["Authorization"] = f"Basic {self._encoded}" addons = [BasicAuthAddon()] ``` Different language, different framework, different hook model. The Java filter is called per-request by LittleProxy's Netty pipeline; the Python addon hooks into mitmproxy's event system. But the behavior maps directly. Including the Java source in the spec let the AI cross-reference both sides without me having to narrate every branch. In practice it caught more than the happy path: the Java filter had a subtle case in the host comparison that the spec called out, and the Python port handled it the same way. ### 2. Unit tests as acceptance criteria Each feature PR had tests, and those tests were the runnable definition of done. The AI could run them, read specific failure output, and adjust against that rather than against my prose description of what was wrong. This removed a slow feedback loop. The tests for each addon were short. For basic auth: ```python from unittest.mock import MagicMock from mitmproxy.test import tflow, tutils from addons.basic_auth import BasicAuthAddon def _make_flow(host: str): return tflow.tflow(req=tutils.treq(host=host)) def _make_config(username="user", password="pass", host_pattern=None): cfg = MagicMock() cfg.basic_auth = MagicMock(username=username, password=password, host_pattern=host_pattern) return cfg def test_injects_header(): addon = BasicAuthAddon() addon.config = _make_config("alice", "s3cr3t") addon._encoded = base64.b64encode(b"alice:s3cr3t").decode() flow = _make_flow("example.com") addon.request(flow) assert flow.request.headers["Authorization"] == f"Basic {addon._encoded}" def test_skips_non_matching_host(): addon = BasicAuthAddon() addon.config = _make_config(host_pattern=r"api\.example\.com") addon._encoded = base64.b64encode(b"user:pass").decode() flow = _make_flow("other.com") addon.request(flow) assert "Authorization" not in flow.request.headers def test_no_op_without_auth_config(): addon = BasicAuthAddon() flow = _make_flow("example.com") addon.request(flow) assert "Authorization" not in flow.request.headers ``` > Note: > > In a real project `_make_flow` and `_make_config` live in `conftest.py`, where `pytest` picks them up automatically across all addon test files. Making these runnable from `./gradlew test` meant wiring pytest into Gradle. The setup is verbose, but each piece has a reason. `packagePython` copies the addons into the build output so the rest of the packaging pipeline can find them: ```groovy tasks.register('packagePython', Copy) { group = 'build' description = 'Copies Python addons and requirements into build output for deployment.' from(pythonSrcDir) into(pythonBuildDir) } assemble.dependsOn('packagePython') ``` Venv creation and requirement installation are two separate tasks so Gradle's up-to-date checks work correctly. `pythonVenvCreate` is skipped by Gradle's incremental build when its declared output — the Python interpreter — already exists. `pythonInstallRequirements` re-runs only when `requirements.txt` changes, tracked via a stamp file: ```groovy tasks.register('pythonVenvCreate', Exec) { group = 'build' description = 'Creates a Python virtual environment under build/python-venv.' def venv = venvDir.get().asFile outputs.file(venvPython) executable = 'python3' args = ['-m', 'venv', venv.absolutePath] } tasks.register('pythonInstallRequirements', Exec) { group = 'build' description = 'Installs proxy Python requirements into the local venv.' dependsOn('pythonVenvCreate') inputs.file(pythonRequirements) def stamp = layout.buildDirectory.file("python-venv/.requirements.stamp") outputs.file(stamp) executable = venvPython.get().absolutePath args = ['-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', '-r', pythonRequirements.absolutePath] doLast { stamp.get().asFile.text = "" } } ``` Finally, the test runner. `PYTHONPATH` is set so test files can import from the addons directory directly, without any `sys.path` manipulation inside the test files themselves: ```groovy tasks.register('pythonTest', Exec) { group = 'verification' description = 'Runs pytest against proxy/src/test/python using the project venv.' dependsOn('pythonInstallRequirements') workingDir = projectDir executable = venvPython.get().absolutePath args = ['-m', 'pytest', pythonTestDir.absolutePath] environment 'PYTHONPATH', "${pythonSrcDir}/addons" onlyIf { pythonTestDir.exists() && pythonTestDir.listFiles()?.any { it.name.endsWith('.py') } } } tasks.named('test') { dependsOn('pythonTest') } ``` Once this was in place, `./gradlew test` ran both Java and Python suites in a single pass. Pytest failures came back as specific assertion messages with exact line numbers and the expected vs. actual values — the same structure the AI was already used to from JUnit. That's the part that mattered. Several addons went through multiple rounds of iteration against real test failures before I ever looked at them. ### 3. End-to-end tests against a real QA site This one changed things more than I expected, and it was a later addition to the project. Earlier on, verifying a new addon meant running a scan by hand, watching the behavior, writing up what was wrong, and feeding that back. Slow, and it required me to be the feedback mechanism, which serialized work that was supposed to be parallel. Once we had E2E tests running against an actual site in a QA environment — real HTTP traffic, real TLS negotiation, real server responses — the AI could close that loop without me. Implement, run the suite, read the failures, adjust, run again. By the time I reviewed a PR, it had already gone through several iterations against real behavior. The output quality was noticeably better than what I was seeing before the E2E tests existed. Synthetic unit tests cover cases you thought of in advance. Real traffic surfaces the ones you didn't. ## Where I still had to step in This didn't run on autopilot. Three categories kept pulling me back in. 1. The subprocess boundary has quirks that are not in the documentation and that I couldn't have anticipated in a spec. The timing of HAR file writes relative to process shutdown. Edge cases in how mitmdump handles specific TLS configurations. Hook ordering in the Python addon system under certain flow types. When these surfaced in failures, I diagnosed them directly, then either wrote the fix or rewrote the relevant part of the spec precisely enough for the AI to implement it. 2. Some behaviors had to match BrowserUp exactly because downstream consumers depended on specific output formats. Others were worth improving, because BrowserUp's behavior in certain cases was a workaround, not a feature. The AI could not make that call. I made it, updated the spec, and the AI implemented the decision. 3. The session config record is shared across all feature PRs. A field added in one addon can interact with logic in another. The AI worked on PRs in isolation and didn't carry that context. I tracked the interactions and flagged them when they became relevant. ## What I'd carry forward The structure of the work mattered far more than any specific capability of the tool. The foundation PR, the per-feature scoping, the written specs, the unit tests, the E2E tests against real traffic: those were the decisions that determined what came out. When the structure was clear, I got code I could ship. When it wasn't, I got code that looked right but needed rewriting. The work that benefited from delegation was the mechanical part: translating a precise behavioral spec into working code, covering each case in tests, iterating on failures until the suite passed. That's a large share of any implementation effort. The work that required judgment didn't go away: diagnosing subprocess quirks, deciding where to match old behavior versus improve on it, designing the foundation that everything depended on. It just got concentrated where it actually mattered. --- # Re-architecting an old service: Part 1 URL: https://chornonoh-vova.com/blog/re-architecting-an-old-service-part-1/ Date: 2026-05-26 > This post is part 1 of the miniseries. Read part 2 [here](/blog/re-architecting-an-old-service-part-2). Every distributed system has a component that everyone quietly dreads touching. Ours was the proxy service. Its job is narrow: sit between the worker tier and the open internet, intercept and record HTTP traffic, and return a structured capture when a session ends. One microservice in a larger system, talked to by workers via a small REST API, configured by a config server, otherwise invisible. The problem was what it ran on. For years, the core was [BrowserUp Proxy](https://github.com/browserup/browserup-proxy), a Java library that embeds directly in the service's JVM. BrowserUp works. It just hasn't had a meaningful release in years. No security patches. No bug fixes. We had accumulated workarounds in our codebase for issues the upstream would never address, and every new capability we needed — new authentication types, reliable chunked encoding behavior, predictable certificate trust — meant writing more code on top of that unmaintained base. We replaced it with [mitmproxy](https://github.com/mitmproxy/mitmproxy), specifically `mitmdump`, the CLI that lets you drive mitmproxy from a script. Where BrowserUp ran inside the JVM, mitmdump runs as a spawned subprocess, one per session. That is a real architectural shift. The rest of the system does not know it happened. The old service was tightly coupled to BrowserUp's internals throughout, so the whole thing needed reworking. Here's what it looks like now:
The tempting path was to patch in-place: swap the library, keep the structure, minimal disruption. I decided against it. If I was going to touch this code anyway, I wanted to leave it in a state where the next replacement, whenever it came, would be hours rather than months. That meant introducing an abstraction. ## The abstraction The only move that makes a replacement like this safe is to define an interface before writing any implementation. We have a `ProxyManager` interface with three methods: `beginRequest`, which initializes a session with the current configuration; `endRequest`, which stops recording and returns a `HarData` object; and `destroy`, which tears everything down. > Note: [HAR]() is a special file format for recording web browser's interactions with the site in a structured JSON archive.
`InternalProxyManager` wraps BrowserUp and handles everything in-process. `MitmproxyManager` manages a mitmdump subprocess. Nothing outside the proxy service touches either concrete class. Workers call the controller's endpoints, the controller calls the interface, and the interface is identical regardless of which implementation runs. That boundary is what let us run both implementations in parallel during the transition and compare their output for the same traffic. A `ProxyManagerFactory` creates the right implementation. A `ProxyManagementController` holds one `ProxyManager` per active port slot and maps the incoming HTTP calls to interface methods.
Spring handles the wiring automatically. A collector bean receives all `ProxyManagerFactory` implementations as a list (`InternalProxyManagerFactory` and `MitmproxyManagerFactory` are both beans themselves), then maps them by type: ```java @Bean public Map proxyManagerFactories( List factories) { return factories.stream().collect( Collectors.toMap( ProxyManagerFactory::getImplementation, Function.identity() ) ); } ``` Spring Boot collects all bean implementations of the `ProxyManagerFactory` interface into a list, and this method turns them into a map. The controller can then create instances on demand: ```java ProxyImplementation impl = request.getProxyImplementation(); ProxyManagerFactory factory = proxyManagerFactories.get(impl); ProxyManager proxy = factory.create(port); ``` ## Which implementation runs We needed control at three levels. A direct caller can specify an implementation for a single session. A job can set a preference that all its sessions inherit. If neither is set, the service falls back to a system-wide deployment property.
This is what made incremental rollout possible. We pointed specific jobs at mitmproxy, watched their captured output against BrowserUp's for the same URLs, found the discrepancies, and expanded. Flipping the system default is a one-line config change. Rolling it back is the same. No consumer code involved either way. ## The subprocess boundary `MitmproxyManager` is where the implementation gets genuinely interesting, because `beginRequest` and `endRequest` are not just method calls. They are process lifecycle events. The new implementation delegates to a purpose-built external binary that runs as a separate process, one per session. That architectural boundary introduced a challenge I hadn't fully anticipated: how do you pass per-session configuration to a process you can't share memory with? At `beginRequest`: serialize the session configuration (headers to inject, authentication credentials, filter rules, the target URL) to a temporary JSON file, then spawn mitmdump with the file path passed as a command-line argument. mitmdump loads Python addons at startup. Each addon reads from that file through a shared singleton. One addon per feature, no shared mutable state between them. At `endRequest`: send SIGTERM, wait for mitmdump to flush its HAR file to disk, parse the HAR into a `HarData` object. At `destroy`: force-kill if still running, delete the temp files. ```txt Worker Controller MitmproxyManager mitmdump │ │ │ │ │── POST ────────▶│ │ │ │ │── allocate slot │ │ │ │ │ │ │── PUT ─────────▶│ │ │ │ │── beginRequest() ──▶│ │ │ │ │── write JSON │ │ │ │── spawn ─────────▶│ │ │ │ │── read addons │ │ │ │── bind port │ │ │ │ │ (traffic flows through proxy) │ │ │ │ │ │── GET ─────────▶│ │ │ │ │── endRequest() ────▶│ │ │ │ │── SIGTERM ───────▶│ │ │ │◀── flush HAR ─────│ │ │ │── parse HAR │ │◀── HarData ─────│◀── HarData ─────────│ │ │ │ │ │ │── DELETE ──────▶│ │ │ │ │── destroy() ───────▶│── cleanup │ ``` Workers still send the same four HTTP calls they always sent. The subprocess complexity is entirely inside `MitmproxyManager`. ## Migration safety The implementation field on a session configuration defaults to null, which the factory resolves to the system default. Every existing session kept using BrowserUp until we changed the deployment property. No backfill was needed. Nothing calling the proxy service required modification. BrowserUp is still in the codebase, still reachable via the override field. When we are ready to remove it, the change will be a deletion. --- In the next post I'll describe how we broke the mitmproxy port into roughly a dozen independently mergeable pull requests and used Claude Code to implement most of them in parallel. Three things made that delegation work: precise written specs per feature, unit tests as runnable acceptance criteria, and end-to-end tests against a real QA site that let the AI iterate without me as the feedback loop. --- # Building worklog, a CLI for my daily reports URL: https://chornonoh-vova.com/blog/building-worklog/ Date: 2026-05-05 Every month, at the end, I sit down to write what I worked on this month. Every standup I scramble to remember what I did yesterday. The data is all in GitHub (PRs, reviews, comments), but a list of titles isn't a report. Skimming the PR list and rewriting it as prose was eating hours every time. So I built `worklog`. One argument: a date. One output: a clean, grouped summary I can paste into Slack. ```bash GITHUB_TOKEN=$(gh auth token) worklog 2026-04-01 ``` Here's how it got there. ## The first sketch I started with the laziest thing that could work: one file, one fetch, pipe the result into Claude. ```bash GITHUB_TOKEN=$(gh auth token) bun index.ts 2026-04-01 | claude --model haiku -p ``` The script just queried GitHub's GraphQL API for `pullRequestContributionsByRepository` and `pullRequestReviewContributionsByRepository`, shaped them into `{ repo: { prs, reviews } }` object, and printed a prompt to stdout. The shell handled the rest. It worked on the first try, which I take as a sign the idea is right and the implementation is wrong. ## Making it one command Two-stage shell pipelines are fine when you're prototyping, but I wanted to type `worklog 2026-04-01` and be done. So I split the script into modules — `github.ts`, `transform.ts`, `claude.ts` — and replaced the pipe with `Bun.spawn`. Same external behavior, one binary. I briefly considered using the Anthropic SDK directly. But that needs a console API key with separate billing, and my Claude.ai subscription doesn't extend to the SDK. Spawning the claude CLI uses my existing Claude Code auth, which is exactly what I want. The subprocess startup costs maybe 50ms against multi-second model calls, so I stopped worrying about it. The architecture at this point looks roughly like this:
Four boundaries: `argv` from the user, a SQLite file on disk, GitHub's GraphQL endpoint, the `claude` subprocess. Everything else is internal plumbing. ## The prompt was the hard part Here's what I didn't expect: the actual code was easy. The prompt was where I spent most of my time. My first prompt was the kind of thing you'd write if you'd never used an LLM before: ```txt You are generating a concise daily work report for a software engineer. Be professional and clear. Group related work. Format as plain text. ``` Output was wildly inconsistent run to run. Sometimes repository names were `worklog`, sometimes `WORKLOG`, sometimes `Worklog`. Sometimes lists used `-`, sometimes `*`, sometimes nothing. Sometimes there was a header, sometimes not. More rules didn't help. What worked was a template plus an example. I gave Claude the exact shape I wanted, and one filled-in version to mirror: ```txt Authored: - Reviewed: - Example: worklog Authored: - Added spinner utility for CLI feedback Reviewed: - Approved getting-started guide updates ``` Plus a few specific bans: no markdown, no bold, always `-` for bullets, and use the repository name verbatim. Output got much more stable. I also sorted the repositories alphabetically in the transform layer before serializing, so the model gets them in a deterministic order. Small thing, but it eliminated a class of "why does the order keep changing" runs. ## Caching, because Claude isn't free Two things made caching feel necessary. First, even on Haiku, summarization takes a few seconds. When I'm re-checking yesterday's report for a standup, those seconds add up. Second, and this is the one that pushed me over: re-running the same date produced different summaries every time. Which is fine in isolation, but if I'd already pasted Monday's report into a doc and then re-ran it on Wednesday to add Tuesday, the Monday section would now read differently. At that point it's a hallucination treadmill, not a report. So I added a SQLite cache. `bun:sqlite` is built into Bun, so there's nothing to install and the binary still compiles to a single file. ```sql CREATE TABLE IF NOT EXISTS worklogs ( date TEXT PRIMARY KEY, summary TEXT NOT NULL ); ``` Two functions, `getCached` and `setCached`, behind prepared statements. The DB lives at `~/.worklog.db`. The flow now: on a cache hit, print the stored summary instantly. If we're in a TTY, ask Regenerate? [y/N]. If not (output is being piped or redirected), just print and exit. On miss, run the pipeline and store the result. Cache writes only happen after the LLM call succeeds, so a network blip never poisons the cache with a half-baked report. The interactive prompt is `node:readline/promises` writing to `stderr`, which keeps `stdout` clean for piping. ## What it looks like now The whole thing is a single-file binary, roughly 60MB compiled (Bun bundles its runtime). Nothing to install, no API key to provision, and no config file to write. Just: ```bash bun build --compile ./src/index.ts --outfile worklog ``` or ```bash bun run build ``` And then `worklog 2026-04-01` whenever I need it. ## What I'd change A few things I'd do differently if starting over: - Cache the GitHub response, not just the summary. Right now, "regenerate" re-fetches GitHub. If the activity hasn't changed since the cached summary, that's wasted. A two-layer cache (raw → summary) would let me regenerate cheaply when I just want a different framing. - Bigger date ranges. The tool is hard-coded to one day. Useful for standups, useless for sprint reviews. Generalizing to a range is mostly a transform-layer change. - Less Haiku, more Sonnet. Haiku is fast and cheap, but Sonnet follows the format template more reliably. For something I'm pasting into Slack, I'd rather pay the latency. But none of those are blocking me from using it daily, which is the bar I care about for a personal tool. --- Source: [worklog repo](https://github.com/chornonoh-vova/worklog) --- # Building a tabata timer: state machines, wake locks, and haptic feedback URL: https://chornonoh-vova.com/blog/tabata-timer-app/ Date: 2026-04-14 My trainer loves giving me Tabata workouts — short, high-intensity rounds of work with rest in-between. There can be prepare and cooldown phases, but they're optional. Here's how it looks on a timeline (example consists of 3 rounds):
So I've decided to build a small web app for it, and learn something new in the meantime. ## State First of all, tabata consists of multiple phases, and we can describe that with a TS union type: ```ts export type Phase = "prepare" | "work" | "rest" | "cooldown" | "done"; ``` For each of the phases, we need to count down the timer. And we need to know how long each phase should take. For that, I've created a config type: ```ts export type Config = { prepare: number; work: number; rest: number; rounds: number; cooldown: number; }; ``` If a phase duration is omitted, it defaults to `0` — meaning that phase is skipped. To assemble all of this - I've created a state type - this is a central piece: ```ts export type State = { phase: Phase; timeLeft: number; round: number; isRunning: boolean; config: Config; }; ``` It has a current phase, how much time is left in the current phase, what round we are on and whether the timer is running or not (to support timer pause). I've also added config to the state so that `nextPhase` can read durations directly when transitioning — no prop drilling needed. ## State machine With all of the types in place, now let's think about the actual logic of the tabata. A state machine models a system that can only be in one of a finite set of states at any time, with explicit rules for moving between them. And there's a natural way to think about tabata as one. Here's a diagram of how it looks:
We can define a function `nextPhase` that will get a current state as an input and will return the new state depending only on the current state: ```ts function nextPhase(state: State): State { switch (state.phase) { case "prepare": return { ...state, phase: "work", timeLeft: state.config.work, }; case "work": { if (state.round === state.config.rounds) { return state.config.cooldown > 0 ? { ...state, phase: "cooldown", timeLeft: state.config.cooldown } : { ...state, phase: "done", isRunning: false }; } return { ...state, phase: "rest", timeLeft: state.config.rest, }; } case "rest": return { ...state, phase: "work", round: state.round + 1, timeLeft: state.config.work, }; case "cooldown": return { ...state, phase: "done", isRunning: false, }; case "done": return state; default: return state; } } ``` In functional programming, this is called a pure function — its output depends only on its input, with no side effects. That means you can unit test every transition without rendering a single component. To wire up this into a React app I first thought about `useState` (multiple or one storing the entire state), but it looked cumbersome to deal with, and then I remembered - `useReducer` exists. It's one of those underrated hooks that is extremely useful in this situation! It gives us a `dispatch` function that sends actions to a reducer — exactly the pattern our state machine needs. Let's take a look at the React hook for tabata timer: ```ts type Action = { type: "PLAY" } | { type: "PAUSE" } | { type: "TICK" }; function init(config: Config): State { return { phase: config.prepare > 0 ? "prepare" : "work", timeLeft: config.prepare > 0 ? config.prepare : config.work, round: 1, isRunning: true, config, }; } function reducer(state: State, action: Action): State { switch (action.type) { case "PLAY": return { ...state, isRunning: true }; case "PAUSE": return { ...state, isRunning: false }; case "TICK": if (!state.isRunning) return state; if (state.timeLeft === 0) { return nextPhase(state); } return { ...state, timeLeft: Math.max(0, state.timeLeft - 1), }; default: return state; } } export function useTabataTimer(config: Config) { const [state, dispatch] = useReducer(reducer, config, init); useEffect(() => { if (!state.isRunning) return; const id = setInterval(() => { dispatch({ type: "TICK" }); }, 1000); return () => clearInterval(id); }, [dispatch, state.isRunning]); const pause = () => { dispatch({ type: "PAUSE" }); }; const play = () => { dispatch({ type: "PLAY" }); }; return { state, pause, play, }; } ``` The reducer handles only 3 actions: `TICK`, `PLAY` and `PAUSE`, there's only one `useEffect` driving the whole state machine - it just emits `TICK` action every second - and then `reducer` and `nextPhase` take care of all of the logic of the tabata. `play` and `pause` functions are the simplest ones - they just pause or continue the timer by flipping one flag. ## User interface UI is simply using the current state of the tabata timer that I'm exposing in a hook, and rendering elements depending on what the current phase is. ```tsx const icons: Record = { prepare: , work: , rest: , cooldown: , done: , }; export function TabataScreen({ onBack }: { onBack: () => void }) { const { config } = useConfig(); const { state, pause, play } = useTabataTimer(config); const isActive = state.isRunning && state.phase !== "done"; useWakeLock(isActive); useFeedback(state); const timeTotal = state.config.prepare + (state.config.work + state.config.rest) * (state.config.rounds - 1) + state.config.work + state.config.cooldown; const timeElapsed = getElapsedTime(state); return (

{icons[state.phase]} {state.phase}

{state.round}/{state.config.rounds}

{state.phase !== "done" && ( <> Total progress {state.timeLeft ? (

{formatTime(state.timeLeft)}

) : (

Next: {state.phase === "prepare" && "Work"} {state.phase === "work" && state.round < state.config.rounds && "Rest"} {state.phase === "work" && state.round === state.config.rounds && (state.config.cooldown ? "Cooldown" : "Done")} {state.phase === "rest" && "Work"}

)} {state.isRunning ? ( ) : ( )} )} {state.phase === "done" && (
  • ✅ {state.config.rounds} rounds completed
  • 🔥 Total work: {formatTime(state.config.work * state.config.rounds)}
  • ⏱️ Total time: {formatTime(timeTotal)}
)}
); } ``` You can notice additional hooks that I didn't talk about - `useWakeLock` and `useFeedback`. Let's talk about them in more detail. ## Screen Wake Lock API When I was testing an app after writing the core logic I noticed the screen dimming after a certain time. Of course I could dig into the settings and disable that, but I didn't want to do that, because I find this feature particularly useful to save the battery. And I started wondering: How other apps such as Instagram or Netflix keep your screen _awake_? And the answer is (for web development at least) - is a Screen Wake Lock API. This API communicates with the device's power management system, preventing the screen from dimming or locking while the timer is active. And the implementation of it is pretty simple: ```ts export function useWakeLock(isActive: boolean) { useEffect(() => { let lock: WakeLockSentinel | null = null; async function request() { try { if ("wakeLock" in navigator) { lock = await navigator.wakeLock.request("screen"); } } catch (err) { console.error("Wake lock failed", err); } } if (isActive) { request(); } return () => { lock?.release(); lock = null; }; }, [isActive]); } ``` Central piece is `WakeLockSentinel` object that is being requested as soon as timer becomes active and is released as soon as it's inactive. ## Feedback The second piece that makes the app feel alive - is sound & haptic feedback. For that I wrote a small hook to do exactly that: for the last 3 seconds of phases play sounds and vibrations. ```ts const tickAudio = new Audio("/sounds/tick.mp3"); const phaseAudio = new Audio("/sounds/phase.mp3"); function playTick() { tickAudio.currentTime = 0; tickAudio.play(); } function playPhaseChange() { phaseAudio.currentTime = 0; phaseAudio.play(); } function vibrateShort() { if ("vibrate" in navigator) { navigator.vibrate(50); } } function vibrateLong() { if ("vibrate" in navigator) { navigator.vibrate([100, 50, 100]); } } export function useFeedback(state: State) { const isCountdownPhase = state.phase === "prepare" || state.phase === "work" || state.phase === "rest"; useEffect(() => { if (!state.isRunning) return; if (!isCountdownPhase) return; if (state.timeLeft > 0 && state.timeLeft <= 3) { playTick(); vibrateShort(); } if (state.timeLeft === 0) { playPhaseChange(); vibrateLong(); } }, [isCountdownPhase, state.timeLeft, state.phase, state.isRunning]); } ``` ## Conclusion This was a fun little project that touched more APIs and patterns than I expected. A tabata timer sounds simple, but getting it right meant thinking about state transitions, hardware APIs, and sensory feedback — things that make the difference between a demo and something you'd actually use at the gym. If I continue building on this, I'd like to explore adding custom workout presets and maybe service worker support for offline use. Full source code of the app is on [GitHub](https://github.com/chornonoh-vova/tabata-timer). And you can open the app [here](https://chornonoh-vova.github.io/tabata-timer/). --- # env() function in CSS (and Tailwind) URL: https://chornonoh-vova.com/blog/env-function-in-css-and-tailwind/ Date: 2026-03-29 Usually, when I want to position an element at the bottom of the screen I use this CSS: ```css .player { position: fixed; bottom: 0; left: 0; right: 0; } ``` This is an example of the player that is positioned at the bottom of the user's screen. It works like a charm for regular websites. But recently I've encountered a problem with this approach in a PWA. Screens (especially mobile) aren't perfect rectangles. They have curved corners and cutouts. And our full-screen apps need to account for that (it's usually not a problem when website or app is viewed inside of the browser). Modern CSS has a solution for this problem: [env()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/env) Here's how it can be used: ```css .player { position: fixed; bottom: env(safe-area-inset-bottom); left: env(safe-area-inset-left); right: env(safe-area-inset-right); } ``` The syntax, as you can see is similar to variables, the only difference though, is that we can define custom variables ourselves, but environment variables are pre-defined. By doing this, I made sure that my floating element in the app remains visible at all times, without being cut out. One important part is this meta tag in HTML: ```html ``` This part (`viewport-fit=cover`) tells the browser that the webpage will fill the entire screen. Without it, browsers typically constrain the webpage to the safe area. Furthermore, because I’m using tailwind, I’ve added such utility classes: ```css @utility bottom-safe { bottom: env(safe-area-inset-bottom, 0); } @utility left-safe { left: env(safe-area-inset-left, 0); } @utility right-safe { right: env(safe-area-inset-right, 0); } ``` Sure, I could've just written `bottom-[env(safe-area-inset-bottom)]` everywhere I need to, and this approach would work the same way, but I personally prefer having dedicated utility classes just for that, instead of complicating markup with such long custom class names. It's much better to write `bottom-safe` instead of that monstrosity 😅 --- # ORMs vs query builders vs raw SQL URL: https://chornonoh-vova.com/blog/orms-vs-query-builders-vs-raw-sql/ Date: 2026-01-25 My experience spans multiple backend ecosystems: - Spring - my main framework, that I'm using every day, - Node.js - in second place, I'm using it in side projects and used it previously. - Rust - I only have a limited experience with it, while developing one of the services, and learning it in my free time. In each of these ecosystems, database access is slightly different, but at the end, they all come down to the basics: SQL. Essentially, you can create a plain string with SQL inside and execute it with appropriate database driver in any language. ## Raw SQL For example, Bun provides native bindings for executing [SQL](https://bun.com/docs/runtime/sql). Here's a little example on how it looks like: ```js const users = await sql` SELECT * FROM users WHERE active = ${true} LIMIT ${10} `; ``` Honestly, it's one of the most straight-forward and easy to use! I find it extremely useful when I need to do some quick prototyping. Rust is unique in that regard, [sqlx](https://github.com/launchbadge/sqlx) library gives compile-time checked queries! ```rust let mut rows = sqlx::query("SELECT * FROM users WHERE email = ?") .bind(email) .fetch(&mut conn); ``` It's extremely powerful when I tried it, but I've only used it in small prototypes, so I don't know how it'll hold with more complicated queries. A nice thing with raw SQL is that you can query database in any way you want, but it's hard to work with at some point. Once queries become dynamic - conditional filters, optional joins, pagination - raw SQL often turns into string concatenation, which is harder to read, refactor, and reason about safely. ## ORMs But let's take a look at other level - ORMs. These are a high level of abstraction on top of the SQL - to the point where you can't be totally sure what SQL actually hits the database when you do some operation 😅 I had the most experience with Hibernate, and it centers about an idea of entities, which essentially represents rows in a table as object. There can be different relationships between them, and Hibernate maps it to the actual database schema. It can be quite powerful as well, with Repositories and auto-generated methods you don't even need to write some of the most common SQL queries. Here's an example of how Hibernate hides problems in the plain sight. Imagine you have the following Entities: ```java @Entity @Table(name = "book") public class Book { @Id private UUID id; private String title; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author_id") private Author author; } @Entity @Table(name = "author") public class Author { @Id private UUID id; private String name; } ``` And somewhere in the code we do this: ```java List books = bookRepository.findAll(); for (Book book : books) { System.out.println(book.getAuthor().getName()); } ``` Well, if you had 50 books there will be 1 query to get all books and one query for each book to get the author. It's an example of classic N+1 problem. That's where you need to know a nitty-gritty details of how Hibernate works, to write the optimized solution: ```java @Query(""" select b from Book b join fetch b.author """) List findAllWithAuthors(); ``` With Hibernate, the number of queries often depends not on the repository method, but on how the returned objects are accessed later. This makes performance characteristics implicit and sometimes surprising. But sometimes - you have to do something outside the box - that ORM disallows or can't do cleanly. That's where in my experience I used JPQL or raw SQL as a fallback, and again that didn't scale well with an amount of dynamic query building that I had to do. To be able to cope with a complexity my team implemented an in-house query builder. ## Query builders That's where query builders come in. I'm using them more and more in my side project, and I think it can be a perfect middle ground. In the past I've used [knex](https://knexjs.org/), but now I'm using [kysely](https://kysely.dev/). They are very similar to each other, here's a snippet of one quite complicated query that I wrote recently: ```ts const booksQuery = db .selectFrom("book") .selectAll("book") .leftJoinLateral( (eb) => eb .selectFrom("readingRun") .select(["bookId", "completedPages", "updatedAt"]) .whereRef("bookId", "=", "book.id") .orderBy("id", "desc") .limit(1) .as("readingRun"), (join) => join.onRef("readingRun.bookId", "=", "book.id"), ) .select((eb) => [ eb.fn.coalesce("readingRun.completedPages", eb.lit(0)).as("completedPages"), eb.fn .coalesce("readingRun.updatedAt", "book.updatedAt") .as("lastUpdatedAt"), ]) .where("userId", "=", userId) .orderBy("lastUpdatedAt", "desc"); const allBooks = await booksQuery.execute(); ``` In runtime, this query will be "compiled" to: ```sql SELECT "book".*, COALESCE("readingRun"."completedPages", 0) AS "completedPages", COALESCE("readingRun"."updatedAt", "book"."updatedAt") AS "lastUpdatedAt" FROM "book" LEFT JOIN LATERAL ( SELECT "bookId", "completedPages", "updatedAt" FROM "readingRun" WHERE "bookId" = "book.id" ORDER BY "id" DESC LIMIT 1 ) AS "readingRun" ON "readingRun"."bookId" = "book"."id" WHERE "userId" = 'some-user-id' ORDER BY "lastUpdatedAt" DESC; ``` If we compare the source code with an SQL output - we can easily spot similarities, even without the experience working with a library. What's even more impressive here - is that it's fully type safe! The one downside for me was getting used to this callback-style syntax. Right now it's quite an exercise to rebuild SQL in my head from the source code. But it was the same with JPQL, and it's all just a matter of experience. Also, this query builder can easily be extended to add more where conditions - a common task that I have to do. Migrations can also be written with query builders - and this gives us an unexpected advantage. Sometimes it's quite hard to write an SQL-only migration, but with a full power of the language that you're working with at your disposal, you can literally do anything! This is one of the pain points of the Hibernate that I have currently: my team writes migrations in the plain SQL files, and you have to be extra careful with the schema-to-entity mapping. Additionally, every once in a while a business-logic heavy migration needs to be written, that you can only write in Java. At that point we have to juggle both SQL and Java migrations at the same time 😅 ## Conclusion In conclusion, I can say that after working with all kinds of database access libraries I gravitate more and more to the query builders such as kysely. I find it extremely valuable that query builder is not trying to hide SQL behind layers and layers of abstractions, but tries to give me the better developer experience in writing it. --- # JSON + metadata = useful logs URL: https://chornonoh-vova.com/blog/json-structured-logging/ Date: 2026-01-18 Observability is important. I didn't understand it before, but after investigating multiple production incidents I get why it's mandatory. I wish I had more information in those moments! But when you are left with one cryptic log message and that's all that you have to work with, unfortunately, you have to guess multiple times and hope that the guess is correct. For these problems I've developed multiple possible scenarios, and proved them imperatively one-by-one. Recently though I had a chance to work on improving observability. I've broken it into two steps: change format and enrich entries with metadata. ## JSON structured logging The first thing that I wanted to try out is JSON logs, after hearing a lot about them, I've finally added them. Turns out, it was easy to change in Spring: ```yaml logging: structured: format: console: ecs file: ecs ``` Usually, these are the benefits that everybody talks about: - they are machine-friendly: easier to parse, filter, and aggregate - consistent structure - better querying - less ambiguity In practice, though, locally it is a nightmare to work with. Until I found how to "prettify" them with jq: ```bash tail -f logs/service.log | \ jq -r '[ .["@timestamp"], .log.level, .message ] | @tsv' ``` This snippet essentially mirrors what was before. But why go through all of the trouble, when we are back at square one? Because we can add some metadata! ## Enriching logs with metadata When I'm looking at logs, it's important for me to know, what they are referring to. Usually we do that with adding breadcrumbs of information here and there in the messages. And sometimes in different formats 😅 For example, we can add a correlation id like that: ```txt correlationId: {uuid} ``` or ```txt correlationId={uuid} ``` or ```txt correlationId = {uuid} ``` These subtle differences add up quickly, and most of the time I've resorted to filtering messages that _include_ a certain ID, and discarding unrelated ones. But with structured logs, there's one and only one way to add them - a JSON field! For example, I went through multiple services, and embedded correlation ids to every message, and, suddenly, I have a way to track down the whole flows even across multiple microservices! Here's an example: ```bash tail -f logs/service.log | \ jq -r 'select (.correlationId == "{uuid}") | [ .["@timestamp"], .log.level, .message ] | @tsv' ``` Crucially, it will work everywhere - in Elastic, in journalctl or just from file logs. It is especially important for my project at work - some environments are on-prem. So that's it for now on this - I'm still learning on how it'll affect my day-to-day work. Hopefully, there will be more updates in the future. --- # Date parsing in JS URL: https://chornonoh-vova.com/blog/js-dates-bug/ Date: 2026-01-10 I've recently encountered an interesting bug: turns out, `Date` constructor behaves differently in different browsers! Let's take a look at the problematic code: ```ts export const formatTimeString = (timeString: string): string => { if (!timeString) return ""; // HH:mm AM/PM to hh:mm:ss const parsedTime = new Date(format(new Date(), "yyyy-MM-dd ") + timeString); if (!parsedTime.getDate()) return ""; // Convert time from 12-hour to 24-hour format const convertedTime = format(parsedTime, "HH:mm:ss"); return convertedTime; }; ``` Looks correct, right? And indeed, this code was in production for multiple years until we noticed that in Safari one of the lesser-used flows was broken. After a long and painful investigation, everything led me to this piece of code, and indeed, if you try to run the following code in Chrome (or Node.js), everything will work: ```js new Date("2026-01-06 09:00 AM"); // Tue Jan 06 2026 09:00:00 GMT+0200 (Eastern European Standard Time) ``` But the same expression will break in the Safari: ```js new Date("2026-01-06 09:00 AM"); // Invalid Date ``` That's because Safari parsing is a lot stricter - it follows ECMAScript spec more closely. But Chrome on the other hand, is a lot more permissive. But also ECMAScript spec only guarantees parsing of a simplified ISO 8601 format. Any other string format is implementation-defined and must not be relied upon. Unfortunately that's exactly what I did 🫠 There's an interesting [Date quiz](https://jsdate.wtf/) that I recommend checking out, which highlights all of the weirdness with `Date` parsing in JS. ## The fix To fix this issue, I've opted out of the built-in `Date` parsing, and utilized [date-fns](https://date-fns.org/) instead. `date-fns` does not delegate parsing to the underlying JS engine. It parses the string itself, making the result deterministic across browsers. Here's how I did it: ```ts const INPUT_TIME_FORMAT = "hh:mm a"; const ISO_TIME_FORMAT = "HH:mm:ss"; export const formatTimeString = (timeStr: string): string => { if (!timeStr) return ""; const parts = timeStr.trim().split(/\s+/); if (parts.length < 2) return ""; const [time, meridiemRaw] = parts; if (!time || !meridiemRaw) return ""; const meridiem = meridiemRaw.toUpperCase(); if (meridiem !== "AM" && meridiem !== "PM") return ""; const [hours, minutes = ""] = time.split(":"); if (!hours) return ""; const toParse = `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")} ${meridiem}`; const parsedTime = parse(toParse, INPUT_TIME_FORMAT, new Date(0)); if (!isValid(parsedTime)) return ""; return format(parsedTime, ISO_TIME_FORMAT); }; ``` I've added here a little bit more code to properly sanitize the full input time string, because this function is used in an input. In a stricter environment (e.g. controlled backend input), this could be simplified to a direct `parse(...)` call. And of course, added a lot more test cases: ```ts describe("formatTimeString", () => { it("handles invalid time string", () => { expect(formatTimeString(" ")).toBe(""); }); it("handles invalid time string - no meridiem", () => { expect(formatTimeString("9")).toBe(""); }); it("handles invalid time string - invalid meridiem", () => { expect(formatTimeString("9 blah")).toBe(""); }); it("handles invalid time string - no hours", () => { expect(formatTimeString(":")).toBe(""); }); it("formats a valid time string - only hours - meridiem lowercased", () => { expect(formatTimeString("10 pm")).toBe("22:00:00"); }); it("formats a valid time string - minutes empty - am", () => { expect(formatTimeString("10: am")).toBe("10:00:00"); }); it("formats a valid time string - full", () => { expect(formatTimeString("10:30 PM")).toBe("22:30:00"); }); it("handles an invalid time string - garbage input", () => { expect(formatTimeString("InvalidTime")).toBe(""); }); }); ``` This problem once more highlights to me an importance of cross-browser testing, because tests that run with Node.js aren't enough 😅 After fixing this problem, I made several important conclusions for myself: - If a date string isn't ISO 8601, don’t pass it to `new Date()` - Parse it explicitly or build the date numerically. --- # i18n-utils: CLI to simplify i18n file management URL: https://chornonoh-vova.com/blog/i18n-utils/ Date: 2026-01-03 In my day-to-day work I'm actively using i18n to localize applications. And we are supporting multiple languages. It's incredibly powerful, but tough to work with sometimes. Here's an example workflow when I'm working on a new feature: 1. Add a key `feature.example.title` with value `Feature example title` to the file with `en` translations. 2. Add the same key but with prefix `*EN*` to all other language files. 3. Wait for the localization team to translate all of the prefixed keys. 4. Update now correctly translated strings. Looks straightforward, doesn't it? But when translation files grow to a couple of thousand lines it becomes quite a challenge to find all of the places where you needed to add/update or sometimes remove a translation altogether. And while we developers don't have any control on how the localization team translates this stuff, it occurred to me that I can automate the first and second steps of the workflow! And in parallel to working on new features I developed a small CLI utility to do exactly that: `i18n-utils` 🚀 Let's look at the features that I've implemented, and how to use them. ## Repository This CLI utility is open-source, and the source code is available in the [repository](https://github.com/chornonoh-vova/i18n-utils). The CLI is written in TypeScript and runs on Node.js. I've also published it to the [GitHub Packages](https://github.com/chornonoh-vova/i18n-utils/pkgs/npm/i18n-utils). After adding this line to your global `.npmrc` file: ```txt @chornonoh-vova:registry=https://npm.pkg.github.com/ ``` You should be able to install it! ```bash npm install -g @chornonoh-vova/i18n-utils ``` It will be available under a name `i18n-utils` and ready to run! ## Help For working with arguments I've used this library: [yargs](https://yargs.js.org/), and when set up correctly, it provides a help out of the box, so you can just run ```bash i18n-utils ``` or ```bash i18n-utils --help ``` and it will show all of the available sub-commands. Subcommand is required when invoking a CLI, so let's talk about them. ## Adding translations `add` sub-command is designed to help with adding new translations to the i18n files. It takes a glob to where the files are located, key and value of the translation and some optional arguments. Here's an example invocation: ```bash i18n-utils add './src/translations/**/translation.json' \ -k 'pageDetail.metadata.command' \ -v 'CLI command' ``` I have this structure of the i18n files in the project: ```txt src\ translations\ en\ translation.json it\ translation.json ja\ translation.json ``` You can actually test this glob by just running `ls`: ```bash ls './src/translations/**/translation.json' ``` And it will show a list of all matching files. As a result of running this command, the following structure will be added to the JSON file for English translation (`./src/translations/en/translation.json`): ```json { "pageDetail": { "metadata": { "command": "CLI command" } } } ``` (without overwriting other translations, of course). And the same structure, but with the value prefixed will be added to all other languages (in the `./src/translations/ja/translation.json` for example): ```json { "pageDetail": { "metadata": { "command": "*EN* CLI command" } } } ``` You can additionally customize the "base" language, prefix and indentation: ```bash i18n-utils add './src/translations/**/translation.json' \ -k 'pageDetail.metadata.command' \ -v 'CLI command' \ -b 'en' \ -p '*EN*' \ -i 2 ``` There's a help available for this sub-command: ```bash i18n-utils add --help ``` ## Updating translations `update` sub-command is designed to overwrite already existing translations in i18n files. And while `add` command will fail if translation already exists, `update` command will fail if the translation does not exist. Invocation looks a lot similar: ```bash i18n-utils update './src/translations/**/translation.json' \ -k 'pageDetail.modal.pageMetadata' \ -v 'Page metadata: {{key}}' ``` And it supports the same options as an `add` command. Actually I created this command only because sometimes I make a typo when running an `add` command 😅 But it turned out to be a helpful command as well! There's a quite cool use case for the update command: ```bash i18n-utils update './src/translations/ja/translation.json' \ -k 'some.example.feature' \ -v 'いくつかの機能例' \ -b ja ``` With this command only one file - one for Japanese translations will be updated. (Don't judge me - I've translated "Some example feature" into Japanese with Google Translate). ## Removing translations `remove` sub-command is designed to simply remove existing translations from all files. It will fail though, if the key does not exist. ```bash i18n-utils rm './src/translations/**/translation.json' \ -k 'pageDetails.modal.noPageContent' ``` Btw all commands have shortcuts, for `add` its `a`, for `update` its `u` and for `remove` its `r` or `rm`. ## Conclusion That's it! If you run into similar i18n pain points, I hope this utility helps streamline your workflow. Feedback and contributions are always welcome! It definitely streamlined my workflow though. And I'm glad that I took a little bit of the time and wrote this utility. --- # Advent of Code 2025, part 2 URL: https://chornonoh-vova.com/blog/advent-of-code-2025-days-7-12/ Date: 2025-12-13 This blog post is part 2 of the mini-series dedicated to the Advent of Code 2025. The first part can be found [in the previous post](/blog/advent-of-code-2025-days-1-6). I felt like the difficulty of the puzzles increased a lot the last couple of days 😅. But let's start with day 7. ## Day 7 On this day, we had to trace the beam and practice both BFS and DFS in action 😎 For the parsing it was incredibly easy - just parse the 2D grid as is: ```ts const START = "S"; const SPLITTER = "^"; type Position = { row: number; col: number; }; type Parsed = { grid: string[][]; start: Position; }; function parse(input: string): Parsed { const grid = input.split("\n").map((line) => line.split("")); const startCol = grid[0].indexOf(START); return { grid, start: { row: 0, col: startCol }, }; } ``` There's one assumption here: that a starting point would always be in the first row. I looked over the example and the actual input and it was the case in both of them. Therefore I simplified the code a little bit 😁 For the actual first part I've decided to go with the BFS because it felt more natural to me - basically, I was simulating the beams just as it was going through the grid. ```ts function countBeamSplits({ grid, start }: Parsed) { let cnt = 0; let beams = [start]; for (let i = 1; i < grid.length; ++i) { const nextBeams: Position[] = []; for (const { row, col } of beams) { const nextRow = row + 1; if (grid[row][col] === SPLITTER) { nextBeams.push({ row: nextRow, col: col - 1 }); nextBeams.push({ row: nextRow, col: col + 1 }); cnt++; } else { nextBeams.push({ row: nextRow, col }); } } beams = []; for (const { row, col } of nextBeams) { const foundIdx = beams.findIndex((b) => b.row === row && b.col === col); if (foundIdx === -1) { beams.push({ row, col }); } } } return cnt; } console.log("part 1", countBeamSplits(parse(input))); ``` There's something that can be improved here - I'm deduplicating the `nextBeams` collection by continuously running `findIndex` - which is inefficient and leads to the `O(n^2)` runtime. But the solution ran pretty quickly even on large input so I've decided to leave it as is. For the second part we need to count all of the "timelines" of all the beams, essentially the number of ways from the start all the way down. I've used DFS this time, but with memoization, because without it, I would need to explore trillions and trillions of paths, and it would take a lot of time. ```ts function countTimelines({ grid, start }: Parsed) { const counts = new Map(); function dfs({ row, col }: Position) { if (row === grid.length) { return 1; } const key = `${row}-${col}`; if (counts.has(key)) { return counts.get(key)!; } let result: number; const nextRow = row + 1; if (grid[row][col] === SPLITTER) { result = dfs({ row: nextRow, col: col - 1 }) + dfs({ row: nextRow, col: col + 1 }); } else { result = dfs({ row: nextRow, col }); } counts.set(key, result); return result; } return dfs(start); } console.log("part 2", countTimelines(parse(input))); ``` ## Day 8 Puzzle for the next day was awesome - and it was even in 3D!, here's a parsing and type definition for it: ```ts type Position = { x: number; y: number; z: number; }; function parse(input: string): Position[] { return input.split("\n").map((line) => { const [x, y, z] = line.split(",").map(Number); return { x, y, z }; }); } ``` Figuring out a distance between the points is a little bit involved, but I've always struggled with math 😅 ```ts function distance(p: Position, q: Position) { return Math.sqrt( Math.pow(p.x - q.x, 2) + Math.pow(p.y - q.y, 2) + Math.pow(p.z - q.z, 2), ); } ``` And the most difficult part was the solution itself (this time I solved it all in one function). I spent a lot of time figuring out how to represent networks and how to merge them. ```ts function first(map: Map): V { return map.values().next().value; } function solution(input: string, iterations: number) { const boxes = parse(input); const n = boxes.length; const minDistances: { boxes: [number, number]; distance: number }[] = []; for (let i = 0; i < n; ++i) { for (let j = i + 1; j < n; ++j) { minDistances.push({ boxes: [i, j], distance: distance(boxes[i], boxes[j]), }); } } minDistances.sort((a, b) => b.distance - a.distance); let id = 0; const powered = new Array(n).fill(false); const networks = new Map>(); const boxToNetwork = new Map(); let box1 = -1, box2 = -1; while ( iterations > 0 || networks.size > 1 || first(networks).size !== boxes.length ) { const min = minDistances.pop()!; [box1, box2] = min.boxes; if (powered[box1] && powered[box2]) { const network1 = boxToNetwork.get(box1)!; const network2 = boxToNetwork.get(box2)!; if (network1 !== network2) { for (const pos2 of networks.get(network2)!) { networks.get(network1)!.add(pos2); boxToNetwork.set(pos2, network1); } networks.delete(network2); } } else if (boxToNetwork.has(box1) || boxToNetwork.has(box2)) { powered[box1] = true; powered[box2] = true; const networkId = (boxToNetwork.get(box1) ?? boxToNetwork.get(box2))!; networks.get(networkId)!.add(box1); networks.get(networkId)!.add(box2); boxToNetwork.set(box1, networkId); boxToNetwork.set(box2, networkId); } else { powered[box1] = true; powered[box2] = true; const networkId = id++; networks.set(networkId, new Set([box1, box2])); boxToNetwork.set(box1, networkId); boxToNetwork.set(box2, networkId); } iterations--; if (iterations === 0) { const networksSnapshot = Array.from(networks.values()); networksSnapshot.sort((a, b) => b.size - a.size); let part1 = 1; for (let i = 0; i < 3; ++i) { part1 *= networksSnapshot[i].size; } console.log("part 1", part1); } } console.log("part 2", boxes[box1].x * boxes[box2].x); } solution(input, iterations); ``` And this solution while not looking pretty, works surprisingly fast, and I started wondering, is there a better approach to this problem? And turns out - there is! In hindsight, this is essentially Kruskal’s algorithm for building a minimum spanning forest - except I reimplemented the "union" logic manually instead of using DSU. I actually wrote about it in the [Maze Generation Algorithms](/blog/maze-generation-algorithms) blog post. Unfortunately, I couldn't see that this data structure is perfect for this problem. I think that I come back later to this problem to solve it "properly". Additionally, it will be a great practice implementing this data structure. ## Day 9 Day 9 and day 10 were the hardest ones for me - I solved the first part really quickly. But I just couldn't figure out the second part. ```ts type Point = [number, number]; function parse(input: string): Point[] { return input.split("\n").map((line) => { const [x, y] = line.split(",").map(Number); return [x, y]; }); } function area([x1, y1]: Point, [x2, y2]: Point): number { const width = Math.abs(x1 - x2) + 1; const height = Math.abs(y1 - y2) + 1; return width * height; } function findMaxArea(input: string) { const points = parse(input); let maxArea = -Infinity; for (let i = 0; i < points.length - 1; ++i) { for (let j = i + 1; j < points.length; ++j) { maxArea = Math.max(maxArea, area(points[i], points[j])); } } return maxArea; } console.log("part 1", findMaxArea(input)); ``` In the example above the code is pretty simple - just looking at max areas of all possible pairs of points. But the second part adds a simple condition that makes it so much harder - now we can only look for rectangles (areas between pairs of points) inside of some polygon. Thanks to my colleague we were able to write a hacky solution based on a lot of math around intersections of lines idea that we had. By researching later I found that this problem is pretty common in game development, and the simple way to solve it is ray casting. Essentially, when we cast a ray from some point we can count the number of intersections with the polygon, and if it's odd, then the point is inside, if there are no intersections or the number is even - point is outside of the polygon. Well, this is day #2 that I need to come back to later. ## Day 10 I liked this day a lot! I'm pretty proud of my solution for the part 1, even though I wasn't able to solve the part 2 myself. ```ts type LightState = { lights: number; presses: number; }; class Machine { requiredLights: number; lightButtons: number[]; constructor(requiredLights: number, lightButtons: number[]) { this.requiredLights = requiredLights; this.lightButtons = lightButtons; } minLightButtonPresses() { const best = new Map(); const queue = new PriorityQueue( (a, b) => a.presses - b.presses, [{ lights: 0, presses: 0 }], ); while (!queue.isEmpty()) { const { lights, presses } = queue.dequeue()!; if (lights === this.requiredLights) return presses; if (best.has(lights) && best.get(lights)! <= presses) continue; best.set(lights, presses); for (const button of this.lightButtons) { queue.enqueue({ lights: lights ^ button, presses: presses + 1, }); } } return -1; } } function parse(input: string): Machine[] { const lines = input.split("\n"); const machines: Machine[] = []; for (const line of lines) { const parts = line.split(" "); let requiredLights = 0; const lightsString = parts[0].substring(1, parts[0].length - 1); for (let i = 0; i < lightsString.length; ++i) { if (lightsString[i] === "#") { requiredLights |= 1 << i; } } const lightButtons: number[] = []; for (let i = 1; i < parts.length; ++i) { const part = parts[i]; if (part.startsWith("(") && part.endsWith(")")) { let lightButton = 0; part .substring(1, part.length - 1) .split(",") .map(Number) .forEach((idx) => { lightButton |= 1 << idx; }); lightButtons.push(lightButton); } } machines.push(new Machine(requiredLights, lightButtons)); } return machines; } function countMinLightButtonPresses(machines: Machine[]) { return machines.reduce( (prev, curr) => prev + curr.minLightButtonPresses(), 0, ); } console.log("part 1", countMinLightButtonPresses(parse(input))); ``` I've implemented Dijkstra's algorithm here. And I've also utilized the bit representation of the lights state here. Adding lights to the requirements became the bit shift and OR. Each button press became XOR and checking for the requirements is just a single comparison. Neat! I thought that my approach would work for the part 2, but there's no chance it would have completed in a thousands of years 🥲 Turns out this is a math problem: each joltage requirement and button presses becomes a equation. And we need to solve the system of these equations to get an answer. I went on Reddit in the hopes of finding the solution there - and I did. But unfortunately, a lot of folks used python and some library called Z3. And while it gives the correct answer, looks like there's a lot of ground in math I need to cover in order to understand it. This makes the day #3 that I need to come back to. ## Day 11 Thankfully day 11 was a breeze - I've managed to complete it pretty quickly with the help of my trusty DFS once again. Here's how I parsed the graph (I've added a couple of command-line args for part 2): ```ts const start = argv[3] || "you"; const required1 = argv[4]; const required2 = argv[5]; const input = (await readFile(filename, "utf-8")).trim(); const OUT = "out"; function parse(input: string) { const graph = new Map(); for (const line of input.split("\n")) { const parts = line.split(" "); graph.set(parts[0].substring(0, parts[0].length - 1), parts.slice(1)); } return graph; } ``` The DFS is essentially the same as in Day 7 part 2: ```ts function countPaths(input: string) { const graph = parse(input); const memo = new Map(); function dfs(curr: string): number { if (curr === OUT) return 1; if (memo.has(curr)) { return memo.get(curr)!; } let total = 0; for (const next of graph.get(curr)!) { total += dfs(next); } memo.set(curr, total); return total; } return dfs(start); } console.log("part 1", countPaths(input)); ``` But part 2 involves a little trick for caching: ```ts function countPathsWithRequired(input: string) { const graph = parse(input); const memo = new Map(); function dfs(curr: string, has1: boolean, has2: boolean): number { if (curr === OUT) { return Number(has1 && has2); } const key = `${curr}|${has1}|${has2}`; if (memo.has(key)) return memo.get(key)!; const next1 = has1 || curr === required1; const next2 = has2 || curr === required2; let total = 0; for (const next of graph.get(curr)!) { total += dfs(next, next1, next2); } memo.set(key, total); return total; } return dfs(start, false, false); } console.log("part 2", countPathsWithRequired(input)); ``` I found out the hard way that trying to remember the whole path is pretty memory-intense. And checking the requirements in this array is inefficient as well - because we need to check every element in the array. Therefore I'm just storing two booleans in the params and memoizing based on them - this drastically improves memory usage and runtime performance. ## Day 12 Day 12 was ... something else. When I initially read it I was shocked, I didn't even know how to approach it. But I did some brainstorming with my friends and just kept on unrolling the problem: - parsing the elements and the regions - doing a quick check for the total area needed - generating rotations and flips of elements for all of the possible positions in a region - transforming those positions into bitmasks for a quick overlap checks (turns out, you can do bitwise operations on BigInts in JS) - implementing the core algorithm - backtracking placement of the elements ```ts type Cell = { row: number; col: number; }; type Region = { width: number; height: number; counts: Map; }; function parse(input: string) { const elements: Cell[][] = []; const regions: Region[] = []; const lines = input.split("\n\n"); for (const line of lines) { if (line.match(/^\d:/)) { const elementStr = line .substring(3) .split("\n") .map((l) => l.split("")); const element: Cell[] = []; for (let row = 0; row < elementStr.length; ++row) { for (let col = 0; col < elementStr[row].length; ++col) { if (elementStr[row][col] === "#") { element.push({ row, col }); } } } elements.push(element); } else { for (const regionStr of line.split("\n")) { const [size, required] = regionStr.split(": "); const [width, height] = size.split("x").map(Number); const counts = new Map(); const presentCounts = required.split(" ").map(Number); for (let i = 0; i < presentCounts.length; ++i) { counts.set(i, presentCounts[i]); } regions.push({ width, height, counts, }); } } } return { elements, regions }; } function rotate(element: Cell[]) { const maxRow = Math.max(...element.map((c) => c.row)); return element.map(({ row, col }) => ({ row: col, col: maxRow - row, })); } function flipHorizontal(element: Cell[]) { const minCol = Math.min(...element.map((c) => c.col)); const maxCol = Math.max(...element.map((c) => c.col)); return element.map(({ row, col }) => ({ row, col: maxCol - (col - minCol), })); } function generateRotations(element: Cell[]) { const result: Cell[][] = []; let current = element; for (let r = 0; r < 4; ++r) { result.push(current); result.push(flipHorizontal(current)); current = rotate(current); } return result; } function generatePlacements({ width, height }: Region, element: Cell[]) { const placements: bigint[] = []; const maxRow = Math.max(...element.map((c) => c.row)); const maxCol = Math.max(...element.map((c) => c.col)); for (let row = 0; row + maxRow < height; ++row) { for (let col = 0; col + maxCol < width; ++col) { let mask = 0n; for (const c of element) { const id = BigInt((row + c.row) * width + (col + c.col)); mask |= 1n << id; } placements.push(mask); } } return placements; } function isValidRegion(region: Region, elements: Cell[][]) { console.log("checking: ", region.width + "x" + region.height, region.counts); const regionSize = region.width * region.height; let elementsSize = 0; for (const [elementIdx, elementRequired] of region.counts) { if (!elementRequired) continue; const element = elements[elementIdx]; const elementSize = element.length; elementsSize += elementSize * elementRequired; } if (regionSize < elementsSize) { console.log("valid:", false); return false; } const variants = new Map>(); for (const [elementIdx, elementRequired] of region.counts) { if (!elementRequired) continue; const element = elements[elementIdx]; const rotations = generateRotations(element); const placements = new Set(); for (const rotation of rotations) { generatePlacements(region, rotation).forEach((placement) => { placements.add(placement); }); } variants.set(elementIdx, placements); } const instances: { shape: number }[] = []; for (const [shape, counts] of region.counts) { for (let c = 0; c < counts; ++c) { instances.push({ shape }); } } instances.sort((a, b) => elements[b.shape].length - elements[a.shape].length); function backtrack(idx: number, occupied: bigint) { if (idx === instances.length) return true; const shape = instances[idx].shape; const options = variants.get(shape)!; for (const mask of options) { if ((mask & occupied) !== 0n) continue; if (backtrack(idx + 1, occupied | mask)) { return true; } } return false; } const valid = backtrack(0, 0n); console.log("valid:", valid); return valid; } function part1(input: string) { const { elements, regions } = parse(input); let count = 0; for (const region of regions) { if (isValidRegion(region, elements)) { count++; } } return count; } console.log("part 1", part1(input)); ``` After all of this code - it just worked first try! I couldn't believe it! This was probably the most complicated thing that I wrote in a long time - so many hard concepts crammed into a single practical task, that's why I love Advent of Code! I already have at least three days I want to revisit, and I’m sure I’ll learn even more the second time around. --- # Advent of Code 2025, part 1 URL: https://chornonoh-vova.com/blog/advent-of-code-2025-days-1-6/ Date: 2025-12-06 Welcome to Advent of Code 2025 🎉 On this page I will be documenting my struggles, solutions and, sometimes visualizations for days of this challenge. ## Boilerplate Before we start for real though, there's some repeating code to read the file that is passed as an argument: ```ts const filename = argv[2]; if (!filename) { console.error("expect filename"); exit(1); } const input = (await readFile(filename, "utf-8")).trim(); ``` Every day begins with this boilerplate, so I'll include it once at the beginning. ## Day 1 The task is pretty straightforward (as I find it later), but I started over-complicating the solution, and it backfired quickly. I've solved the first part mathematically: ```ts const instructions = input.split("\n"); class Safe { #dial: number; cnt: number; constructor(initial: number) { this.#dial = initial; this.cnt = 0; } turnLeft(amount: number) { this.#dial = (((this.#dial - amount) % 100) + 100) % 100; this.#checkCounter(); } turnRight(amount: number) { this.#dial = (this.#dial + amount) % 100; this.#checkCounter(); } #checkCounter() { if (this.#dial === 0) { this.cnt++; } } } const safe = new Safe(50); for (const instruction of instructions) { const dir = instruction.substring(0, 1); const amount = parseInt(instruction.substring(1)); if (dir === "L") { safe.turnLeft(amount); } else { safe.turnRight(amount); } } console.log("part 1:", safe.cnt); ``` But for the second part... Well, I couldn't figure out how to calculate everything essentially in one operation. So I switched back to simulating the whole process, and in doing so, I've simplified and refactored it a lot: ```ts type Direction = "L" | "R"; class Safe { #dial: number; part1: number; part2: number; constructor(initial = 50) { this.#dial = initial; this.part1 = 0; this.part2 = 0; } turn(direction: Direction, amount: number) { const dir = direction === "L" ? -1 : 1; for (let i = 0; i < amount; ++i) { this.#dial += dir; this.#dial %= 100; if (!this.#dial) { this.part2++; } } if (!this.#dial) { this.part1++; } } } const safe = new Safe(); for (const instruction of instructions) { const direction = instruction.substring(0, 1) as Direction; const amount = parseInt(instruction.substring(1)); safe.turn(direction, amount); } console.log("part 1:", safe.part1); console.log("part 2:", safe.part2); ``` And this was quite a good starting point for visualization, that I set out to implement, after seeing some inspiring visuals on Reddit. Here it is: You can click "Simulate" to start an animation and "Reset" to start over. ## Day 2 The second day was more straightforward to me, and it begins with parsing, which is basically splitting ranges and splitting every range individually: ```ts const ranges = input.split(",").map((r) => r.split("-").map(Number)); ``` The first part of the puzzle is to find the "invalid" numbers in the ranges that we've just parsed, and sum them up. An invalid number is a number in which the sequence of digits repeats twice. For example `11`, `123123` or `1122511225`. I've decided to split every number's string representation in the middle and just compare two parts. Here's my implementation: ```ts const invalid1: number[] = []; for (const [start, end] of ranges) { for (let n = start; n <= end; ++n) { const s = n.toString(); if (s.length % 2 !== 0) { continue; } const [left, right] = [ s.substring(0, s.length / 2), s.substring(s.length / 2), ]; if (left === right) { invalid1.push(n); } } } const part1 = invalid1.reduce((prev, curr) => prev + curr, 0); console.log("part 1:", part1); ``` Additionally, I'm skipping the odd-length numbers, as they won't have repeating sequences anyway. The second part was trickier though. Now, the sequences of numbers can repeat more than two times. For example, `121212` previously would have been considered valid, but with new rules, it will be considered invalid. To implement this logic, I'm iterating over all possible sizes (from 1 and up to `s.length / 2`) and splitting the string into multiple parts. And to verify whether the number is invalid I'm comparing every part. Here's my implementation: ```ts const invalid2: number[] = []; for (const [start, end] of ranges) { for (let n = start; n <= end; ++n) { const s = n.toString(); for (let size = 1; size <= Math.floor(s.length / 2); ++size) { if (s.length % size !== 0) { continue; } const parts: string[] = []; for (let i = 0; i < s.length; i += size) { parts.push(s.substring(i, i + size)); } if (parts.every((p) => p === parts[0])) { invalid2.push(n); break; } } } } const part2 = invalid2.reduce((prev, curr) => prev + curr, 0); console.log("part 2:", part2); ``` You can notice a sneaky little `break` after we've identified an invalid number, it is needed there to avoid counting the same number multiple times. For example, number `222222` can be split up as `['2','2','2','2','2','2']` or `['22','22','22']` or `['222','222']` which all satisfy our requirement. And in the first version I've pushed the same number multiple times 😅 Which led to over-counting. Another way to solve this issue was to store all of the invalid numbers in a `Set`, as it allows only unique numbers. But this approach is much simpler, in my opinion. ## Day 3 On this day, the task was really interesting: find consecutive maximums in array of digits. But let's start with parsing: ```ts const banks = input.split("\n").map((line) => line.split("").map(Number)); ``` It's pretty straightforward - every line is transformed into array of numbers. For the first idea, I immediately jumped into solving the problem with brute force approach, and it worked fine for 2 maximums that are required in the first part. But for the second part you need to find 12 maximums! Of course, you can write 12 `for` loops inside of one another 🙃 But I took a step back to re-think my brute-force solution and improve it to be linear. The idea is to greedily find the maximum number possible, remember it, and then start looking for maximum but from the next index of the previous one. The main catch for me in this task was to limit the index that we are looking for. For example, if we are looking for the first number, we can look up to the `batteries.length - 11` index, for second - up to `batteries.length - 10` and when looking for 12th - up to the end of the array. Here's how I implemented both parts in one function: ```ts function findTotalJoltage(size: number) { const maximums: number[] = []; for (const batteries of banks) { let max = 0; let maxIdx = -1; for (let s = size - 1; s >= 0; --s) { let maxInner = -1; for (let i = maxIdx + 1; i < batteries.length - s; ++i) { if (batteries[i] > maxInner) { maxInner = batteries[i]; maxIdx = i; } } max = max * 10 + maxInner; } maximums.push(max); } return maximums.reduce((prev, curr) => prev + curr, 0); } console.log("part 1:", findTotalJoltage(2)); console.log("part 2:", findTotalJoltage(12)); ``` There's also a bonus trick here: instead of converting every digit back to string, concatenating and then parsing the whole result back to number, this line: ```ts max = max * 10 + maxInner; ``` Will instead directly add a digit to the number! ## Day 4 Fourth day was a breeze: I literally solved it in 10 minutes! Let's take a look how, first by parsing the input: ```ts const initialGrid = input.split("\n").map((line) => line.split("")); ``` Again, the parsing is straightforward - just split the lines and then split every character. The core of the task is to iterate over all cells and identify whether the cell is accessible or not. I'm doing it with the help of a trusty `directions` array which just contains the coordinate differences for all 8 neighbors. And then I'm iterating over all neighbors of the given cell and counting rolls. Here's how it looks: ```ts const ROLL = "@"; const EMPTY = "."; const directions = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1], ]; function isAccessible(grid: string[][], row: number, col: number) { const n = grid.length; const m = grid[0].length; let adjRolls = 0; for (const [dr, dc] of directions) { const [r, c] = [row + dr, col + dc]; if (r >= 0 && r < n && c >= 0 && c < m && grid[r][c] === ROLL) { adjRolls++; } } return adjRolls < 4; } ``` Then, for the first part, it's only a matter of iterating over all cells in a grid and keeping a count of all accessible ones: ```ts function countAccessibleRolls(grid: string[][]) { const n = grid.length; const m = grid[0].length; let accessible = 0; for (let row = 0; row < n; ++row) { for (let col = 0; col < m; ++col) { if (grid[row][col] !== ROLL) { continue; } if (isAccessible(grid, row, col)) { accessible++; } } } return accessible; } console.log("part 1", countAccessibleRolls(initialGrid)); ``` For the second part there's a twist: now we need to remove all accessible rolls until there's no accessible rolls anymore. But the implementation is straightforward: iterate over all cells, save the coordinates that we'll be removing in set, and then remove them! Here's how it looks like: ```ts function countRemovableRolls(grid: string[][]) { const n = grid.length; const m = grid[0].length; let removable = 0; while (countAccessibleRolls(grid) !== 0) { const toRemove = new Set(); for (let row = 0; row < n; ++row) { for (let col = 0; col < m; ++col) { if (grid[row][col] !== ROLL) { continue; } if (isAccessible(grid, row, col)) { toRemove.add(`${row}-${col}`); } } } for (const rollPos of toRemove) { const [row, col] = rollPos.split("-").map(Number); grid[row][col] = EMPTY; removable++; } } return removable; } console.log("part 2", countRemovableRolls(initialGrid)); ``` The second part reuses both functions from part 1. And I built a little interactive playground below: In this playground, accessible rolls are marked with red color. Every time when you click "Remove" button, they will be removed, and the count of removed rolls will be increased by that amount. Count of accessible rolls will be recalculated. And, once you no longer have accessible rolls, "Remove" button will be disabled. But you can click "Reset" button to start over. # Day 5 First order of business - parsing. This time input is split into two parts; “fresh” ID ranges and available IDs. They have different formats: fresh IDs are pairs of numbers separated by “-“. Available IDs are represented simply as one number per line. Here’s the code to implement parsing: ```ts function parse(input: string) { const [freshRangesStr, availableIngredientsStr] = input.split("\n\n"); const freshRanges = freshRangesStr .split("\n") .map((line) => line.split("-").map(Number)); freshRanges.sort((a, b) => a[0] - b[0] || a[1] - b[1]); const availableIngredients = availableIngredientsStr.split("\n").map(Number); return { freshRanges, availableIngredients }; } ``` We need to find which of the available IDs are fresh. My first idea is to perform brute force: save all of the possible fresh IDs into a `Set` and iterate over all available products while identifying if they belong to a `Set` in `O(1)`. While sounding perfectly reasonable and working excellently on the example input, it fails on the full input. With an error that I’ve never seen before: ```txt RangeError: Set maximum size exceeded ``` Turns out we can’t just put all of the numbers in a `Set` after all 😅 That’s where the second idea comes into play: iterate over all available and inside iterate over all ranges, and if it falls within the range, increment the counter. But there’s a slightly different problem: now I’m double-counting, because sometimes number falls into multiple ranges simultaneously 🤔 But my LeetCode grind wasn’t for nothing after all - I remembered that I solved a similar problem before: [56. Merge Intervals](https://leetcode.com/problems/merge-intervals/) Here’s the code that merges ranges: ```ts function merge(ranges: number[][]) { const merged = [ranges[0]]; for (let i = 1; i < ranges.length; i++) { const lastEnd = merged[merged.length - 1][1]; const [nextStart, nextEnd] = ranges[i]; if (nextStart <= lastEnd) { merged[merged.length - 1][1] = Math.max(lastEnd, nextEnd); } else { merged.push(ranges[i]); } } return merged; } ``` The code above requires the intervals to be sorted already, that's why that sneaky sort is included in the `parse` function 😉 Here’s how I implemented part 1 task: ```ts function countFreshAvailable() { const { freshRanges, availableIngredients } = parse(input); const mergedFreshRanges = merge(freshRanges); let cnt = 0; for (const available of availableIngredients) { for (const [start, end] of mergedFreshRanges) { if (available >= start && available <= end) { cnt++; } } } return cnt; } console.log("part 1", countFreshAvailable()); ``` And with the merging implemented part 2 becomes even simpler: ```ts function countAllFresh() { const { freshRanges } = parse(input); const mergedFreshRanges = merge(freshRanges); let cnt = 0; for (const [start, end] of mergedFreshRanges) { cnt += end - start + 1; } return cnt; } console.log("part 2", countAllFresh()); ``` ## Day 6 This day was tough 😅 First part was relatively simple, here's a parser to parse input for the first part: ```ts function parse1(input: string) { let lines = input.split("\n"); const parsed = lines .slice(0, -1) .map((line) => line.trim().split(/\s+/).map(Number)); const operations = lines[lines.length - 1].trim().split(/\s+/); const nums: number[][] = []; for (let i = 0; i < operations.length; ++i) { const col: number[] = []; for (let j = 0; j < parsed.length; ++j) { col.push(parsed[j][i]); } nums.push(col); } return { nums, operations }; } ``` And the function that calculates the final results (after many iterations I made it so it works for the both parts): ```ts function calculate({ nums, operations, }: { nums: number[][]; operations: string[]; }) { return nums .map((col, idx) => { let total = col[0]; const op = operations[idx]; for (let i = 1; i < col.length; ++i) { if (op === "+") { total += col[i]; } else if (op === "*") { total *= col[i]; } } return total; }) .reduce((prev, curr) => prev + curr, 0); } ``` Here's how I'm invoking everything: ```ts console.log(calculate(parse1(input))); ``` But for the second part, parsing was tough. I had to take a break and go to the gym, and honestly - I'm thankful that I did. Because I was able to think about the problem more and "write" the pseudocode in the Notes on my phone. And after I got back to my laptop, I basically implemented my idea in like 15-20 minutes. Here's my implementation for the second part: ```ts function parse2(input: string) { let lines = input.split("\n"); const operations = lines[lines.length - 1].trim().split(/\s+/); lines = lines.slice(0, -1); const nums: number[][] = Array.from({ length: operations.length }, () => []); let curr = 0; for (let i = 0; i < operations.length; ++i) { while (curr < lines[0].length) { let num = ""; for (let j = 0; j < lines.length; ++j) { num += lines[j][curr]; } curr++; num = num.trim(); if (!num) break; nums[i].push(Number(num)); } } return { nums, operations }; } ``` And the invocation looks really similar: ```ts console.log(calculate(parse2(input))); ``` ## Wrap up Six days down, many more to go. If the cephalopods, rolls of papers, and safe dials are any indication, Advent of Code 2025 is just warming up. My brain may already be slightly overclocked — but that’s part of the fun. See you in the next batch of puzzles! --- # Bash "strict" mode URL: https://chornonoh-vova.com/blog/bash-strict-mode/ Date: 2025-11-29 I always start my bash scripts with the following lines: ```bash #!/usr/bin/env bash set -euo pipefail # ... rest of the script ``` And I got asked recently by my coworkers, why. And to be honest with you, I couldn't remember it from the top of my head, because there's so much hidden in these two simple lines. It just felt like I was doing it forever, and, quite frankly, automatically at that point. But what do these lines do exactly? Let's break it down. ## The shebang `#!/usr/bin/env bash` is a "shebang" line at the beginning of a script. When operating system encounters a line starting with `#!` at the very beginning of a file, it knows to execute the rest of the file as a command to run the script. Interestingly, we can put anything in the shebang, for example we can craft this file: ```txt #!/bin/cat test! test!! test!!! ``` And when you execute it, it just prints itself! You've probably noticed that, instead of specifying `/bin/bash` directly, it uses `/usr/bin/env bash`, and this has a couple of benefits: - It ensures a script can run on different systems, even if `bash` is installed in a non-standard location, such as in a user's home directory. - It allows a user to use a custom or different version of `bash` they may have installed in their `$PATH`, rather than the system's default. Basically, the `env` command searches the directories listed in the `$PATH` environment variable for the `bash` executable. And, it executes the script using the first `bash` interpreter it finds. These two little things allow us to do powerful things, like executing the rest of the script with `perl` or `ruby`, for example: ```bash #!/usr/bin/env perl ``` ```bash #!/usr/bin/env ruby ``` ## Shell options The second line (`set -euo pipefail`) sets three options for the shell: 1. `set -e` (can also be written as `set -o errexit`) tells bash to exit immediately if any command fails with a non-zero exit status. By default, `bash` does not do that! And it makes sense, because usually `bash` runs in interactive mode (i.e. when user inputs commands) and it would be pretty annoying if it quit after every failed command 😅. But it's important to set it for scripts, because almost every time, commands are meant to execute one after the other, and when one fails, script shouldn't be executed further. Note: it doesn’t trigger in every case, for example, inside `if` conditions or some compound commands. It catches most errors, but not all. 2. `set -u` (can also be written as `set -o nounset`) tells bash to exit immediately if it encounters an undefined variable. Pretty simple! But it's important when writing scripts, because by default it will just silently error, and just substitute empty string if used in string, for example. Yikes 😬 3. `-o pipefail` tells bash to make the pipeline's exit status reflect the failure status of any command in the pipeline, not just the last one. By default, only the exit status of the last command in the pipeline is used as the return status of the entire pipeline. Additionally, there's `set -x` or `set -o xtrace` option available, that is really useful for debugging, because it prints every command with expanded arguments before they are getting executed. Usually, I'm setting this option when writing the script or when I'm trying to figure something out, but leave it out in "production" scripts. Read more in [this article](https://olivergondza.github.io/2019/10/01/bash-strict-mode.html) or in the [documentation](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). Omitting these options can lead to some disastrous bugs. Here are some YouTube videos that break it down: - [Steam bug, unset variable](https://www.youtube.com/watch?v=qzZLvw2AdvM) - [Cloudflare bug, pipefail](https://www.youtube.com/watch?v=kUtarOlOT3Y) There are extended versions of this "strict" mode for bash scripts out there, that additionally set `IFS` variable, for example. You can read more in-depth information in this [blog post](http://redsymbol.net/articles/unofficial-bash-strict-mode/). ## Automatic script creation I even have this little script to make other scripts! ```bash #!/usr/bin/env bash set -euo pipefail if [ ! $# -eq 1 ]; then echo "mksh takes one argument" 1>&2 exit 1 elif [ -e "$1" ]; then echo "$1 already exists" 1>&2 exit 1 fi echo '#!/usr/bin/env bash set -euo pipefail '>"$1" chmod +x "$1" "${EDITOR:-vim}" "$1" ``` Shout out to this [awesome blog post](https://evanhahn.com/scripts-i-wrote-that-i-use-all-the-time/) that opened my eyes to how I can automate this process! Now I can just run: ```bash mksh some-script.sh ``` (Given that the path is configured like so in `~/.zshrc`): ```bash export PATH=$HOME/bin:$PATH ``` And voila, the script will be created and I'm ready to edit it instantly. ## Wrapping up It still amazes me how much power hides in those two little lines at the top of a script. They make Bash behave more responsibly, catch mistakes before they cascade, and save hours of debugging you never had to do in the first place. If you’re writing Bash scripts and not using them yet, give it a try. Your future self will thank you. That's it for today, safe scripting y'all 👋 --- # XSS security vulnerability URL: https://chornonoh-vova.com/blog/xss-security-vulnerability/ Date: 2025-11-22 Recently, we discovered an XSS vulnerability at work, and I jumped in to fix it. And here’s the funny part: even though I’ve heard about XSS thousands of times throughout my career, I completely failed to recognize it at first. So let’s walk through how this vulnerability happened, why it was so easy to miss, and what we can do to prevent issues like this in the future. ## Origins This vulnerability didn’t come out of nowhere. It appeared because several architectural choices aligned in just the wrong way: 1. Generating and storing the HTML in the database This limits your ability to validate or sanitize content at the point of entry. 2. Returning raw HTML string in the API request Whatever HTML the server sends will be trusted by the client. 3. Injecting raw HTML into the component …and this is where everything falls apart. When writing this I can see a lot of red flags, and I'm sure you are as well 😅 In hindsight, it’s a perfect storm. Each step looks innocent in isolation, but together they create a straight path for untrusted content to reach the browser. If you’re using React, you probably know that `dangerouslySetInnerHTML` is intentionally scary. It bypasses React’s safety mechanisms and tells React: **Trust me bro, I know what I’m doing.** Here's how it looked like in the rendering code: ```tsx function ExampleComponent() { const { data } = useSWR("/api/data", fetcher); const createContent = () => { return { __html: data.html }; }; return (
); } ``` Of course, this example is simplified to a point, when I'm showing only the relevant parts of the vulnerability. And I can see now that it's pretty much a textbook example of the XSS injection. A simple vulnerable HTML can look like this: ```html ``` Inline event handlers like `onerror`, `onload`, and `onclick` are the most common XSS vectors, because browsers execute them automatically, with no user interaction required. And while we are not allowing user-submitted HTML in the database (because we are generating it ourselves), it's always a good idea to have extra protection in place. Because when the database gets compromised, it would not allow further exploitation of the system through XSS, for example. And that's why such "safe" (at first glance) pieces of data can be so dangerous. ## Fixing it Thankfully, there's a great library available: [DOMPurify](https://github.com/cure53/DOMPurify). DOMPurify does one thing extremely well: it takes untrusted HTML and strips anything that could execute JavaScript or break out of its sandbox. It’s small, fast, actively maintained, and used across many production systems. With it, sanitization looks as simple as this: ```ts const cleanHtml = DOMPurify.sanitize(dirtyHtml); ``` For my use case, though, I had to use a little wrapper around this library: [isomorphic-dompurify](https://github.com/kkomelin/isomorphic-dompurify), because the project is using [jest](https://jestjs.io/) to perform testing. Jest tests run in a Node.js environment, not a real browser, so there’s no DOM available. DOMPurify expects window, document, and other browser globals, so it fails in tests. This wrapper library takes care of that, and allows for the same code to run perfectly fine in the browser and in unit test environments. Updated example from before: ```tsx function ExampleComponent() { const { data } = useSWR("/api/data", fetcher); const createContent = () => { const cleanHtml = DOMPurify.sanitize(data.html); return { __html: cleanHtml }; }; return (
); } ``` And here's the test that I've added to make sure that HTML coming from the server is properly sanitized: ```tsx it("should sanitize incoming html", () => { useSWR.mockReturnValue({ data: { html: '', }, }); const { container } = render(); expect(container).toMatchSnapshot(); }); ``` The snapshot ensures that the final rendered markup contains no inline event handlers, scripts, or anything else that could lead to code execution. If sanitization ever breaks, this test will fail immediately. ## Future There's surprising new developments in this space as well. There's a proposal to add a Sanitizer API into the browser. Potentially it will allow us to not rely on the third-party libraries in the future, and do everything natively! There's a great [blog post](https://olliewilliams.xyz/blog/sanitizer/) about this new API that I suggest reading. I'll definitely come back to try this API out when browsers will fully support it! ## Bonus: HTML to text in Java Related to that HTML-in-the-database thing there was a similar challenge: when exporting, I needed to transform the HTML into plaintext. Here's how I did it in Java: ```java public static String issueFixHtmlToText(String html) { if (!StringUtils.hasText(html)) { return ""; } String replaced = html.replace("summary:", "") .replace("issue.fix.any", "Fix Any:") .replace("issue.fix.all", "Fix All:") .replace("    issue.relatedNodes:", "Related nodes:") .replace("    ", " - "); return Jsoup.parse(replaced).wholeText().replaceAll("\\n+", "\n").trim(); } ``` To do that, I've used one more useful library: [jsoup](https://jsoup.org/). This library could actually be used to cleanup HTML as well, and also to manipulate and transform HTML from Java code. All in all - it's a great tool to have in your Java toolbox. ## Conclusion This was a good reminder for me that even well-understood vulnerabilities like XSS can sneak into production if the architecture allows it. These issues rarely look dangerous at first glance, but they become dangerous when the right pieces line up. Hopefully this post helps you spot similar pitfalls in your own systems, and gives you a clear path to fix them when they appear. --- # Building local-first app with IndexedDB URL: https://chornonoh-vova.com/blog/building-local-first-app-with-indexed-db/ Date: 2025-11-15 This week I'm digging into TanStack Router & Query documentations to build a foundation for my application. And while I haven't achieved much, I believe I've established quite a few powerful patterns and learned interesting concepts while working with new libraries for me. Let's walk through my setup and what I've learned along the way. ## Building a small IndexedDB wrapper I've briefly mentioned IndexedDB in my last week blog post, and this week I'm learning on how to actually use it in practice. So let's start by writing a small wrapper around IndexedDB that will simplify our life when working with it in the future. It might look like an abstraction just for the sake of abstraction, but believe me, you'll see the value in it! ![pleaseNoNotAnotherBaseClassHelper](../../assets/images/please-no-not-another-base-class-helper.JPG) [Source](https://www.reddit.com/r/ProgrammerHumor/comments/1cu7f29/pleasenonotanotherbaseclasshelper/) This wrapper is responsible for maintaining database connection and provides a couple of convenient methods for performing queries. ```ts export class IndexedDBWrapper { #db: IDBPDatabase | null; constructor() { this.#db = null; } async getDb() { if (!this.#db) { this.#db = await this.#initDB(); } return this.#db; } #initDB() { return openDB(DB_NAME, 1, { upgrade(db) { db.createObjectStore("storymaps", { keyPath: "id" }); db.createObjectStore("activities", { keyPath: "id" }); db.createObjectStore("stories", { keyPath: "id" }); db.createObjectStore("releases", { keyPath: "id" }); }, }); } async readAll>( storeName: TName, ): Promise[]> { const db = await this.getDb(); const tx = db.transaction(storeName, "readonly"); const store = tx.objectStore(storeName); return store.getAll(); } async write>( storeName: TName, value: StoreValue, ) { const db = await this.getDb(); const tx = db.transaction(storeName, "readwrite"); const store = tx.objectStore(storeName); await store.put(value); await tx.done; } } ``` This class is holding a reference to the database in a private variable and opens up a connection to the database lazily, when `getDb` is called for the first time. It is possible to start the `#initDB` in constructor, but I've wanted to be able to catch errors related to database opening in the React error boundary as well as any other error to simplify the error handling logic. `readAll` and `write` generic method definitions look a little bit scary, and honestly, it took me a bit of time to figure them out. But the result is very powerful - full type safety when invoking them, which means that it's not possible to invoke these methods with a name of the store that doesn't exist in the database. And, additionally, return types are automatically inferred based on the store name as well! ## Integrating with React To connect this wrapper with React I've set up a context to provide this helper for various hooks that might need it. ```tsx const DatabaseContext = createContext(null); export function DatabaseProvider({ children, wrapper, }: { children: ReactNode; wrapper: IndexedDBWrapper; }) { return ( {children} ); } export function useIndexedDBWrapper() { const context = useContext(DatabaseContext); if (!context) throw new Error("useIndexedDBWrapper must be used within DatabaseProvider"); return context; } ``` I love this pattern that allows for clean usage of `useIndexedDBWrapper` hook whenever its needed instead of doing `useContext(DatabaseContext)` everywhere. It additionally throws an exception and provides a hint to the TypeScript: when this hook is called, the result of it will never be undefined! I think it's really powerful concept, and it's my go-to method when creating and using contexts in my apps. ## Wiring it up with TanStack Router & Query Additionally, I'm providing database wrapper along with a query client to the router context: ```tsx const databaseWrapper = new IndexedDBWrapper(); const queryClient = new QueryClient(); // Create a new router instance const router = createRouter({ routeTree, context: { databaseWrapper, queryClient, }, defaultPreload: "intent", scrollRestoration: true, defaultStructuralSharing: true, defaultPreloadStaleTime: 0, }); // Render the app const rootElement = document.getElementById("app"); if (rootElement && !rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( , ); } ``` This way, both query client (a backbone of the TanStack Query) and my database wrapper will be available in the route loaders. Here's an example of a loader that ensures that data is available to be rendered in the route component: ```ts export const Route = createFileRoute("/")({ component: StoryMaps, loader: ({ context }) => { return context.queryClient.ensureQueryData( storyMapsQueryOptions(context.databaseWrapper), ); }, errorComponent: StoryMapsError, }); ``` Error handling for the route: ```tsx function StoryMapsError({ error }: { error: Error }) { const router = useRouter(); const retry = () => { router.invalidate(); }; return (
{error.message}
); } ``` While looking simple, it's powerful as well! All of the errors are caught by this error boundary, and retries are just one call away! Route component itself that uses the data and performs a mutation. ```tsx function StoryMaps() { const { data: storymaps } = useStoryMapsSuspenseQuery(); const addStoryMapMutation = useAddStoryMapMutation(); const addStoryMap = () => { addStoryMapMutation.mutate({ id: crypto.randomUUID(), name: "test", description: "test", }); }; return (
{storymaps.map(({ id, name, description }) => ( {name} ({id}) {description} ))}
); } ``` The interesting thing here is that `storymaps` data will never be undefined, the responsibility of the component is primarily to render the data. Loading state can be handled by the separate pending component, and error state is handled separately as well. ## Queries and Mutations Now let's take a look at the suspense query for data and a mutation with optimistic update: ```ts const storyMapsQueryKey = ["storymaps"]; export function storyMapsQueryOptions(wrapper: IndexedDBWrapper) { return queryOptions({ queryKey: storyMapsQueryKey, queryFn: () => wrapper.readAll("storymaps"), }); } export function useStoryMapsSuspenseQuery() { const wrapper = useIndexedDBWrapper(); return useSuspenseQuery(storyMapsQueryOptions(wrapper)); } export function useAddStoryMapMutation() { const wrapper = useIndexedDBWrapper(); return useMutation({ mutationFn: (newStoryMap: StoryMap) => wrapper.write("storymaps", newStoryMap), onMutate: async (newStoryMap, context) => { await context.client.cancelQueries({ queryKey: storyMapsQueryKey }); const prevStoryMaps = context.client.getQueryData(storyMapsQueryKey); context.client.setQueryData(storyMapsQueryKey, (old: StoryMap[]) => [ ...old, newStoryMap, ]); return { prevStoryMaps }; }, onError: (error, _newStoryMap, onMutateResult, context) => { console.error(error); if (onMutateResult?.prevStoryMaps) { context.client.setQueryData( storyMapsQueryKey, onMutateResult.prevStoryMaps, ); } }, onSettled: (_data, _error, _variables, _onMutateResult, context) => { context.client.invalidateQueries({ queryKey: storyMapsQueryKey, }); }, }); } ``` The query is pretty simple - it just returns the data by the given query key. The only important thing here is that query key needs to be the same in the loader and in the query hook. That's why I've defined it as a separate function to reuse it in both places - and `queryOptions` helps with that. The mutation here is much more involved, let's break it down: - `mutationFn` is an async function that performs some action - in our case, adding data to the IndexedDB. In other cases it can be a request to the backend, for example. - `onMutate` is a heart of the logic: it mutates the internal state of the query client and appends a new data that we are adding immediately, so that UI can be updated even before the actual mutation is completed. It additionally saves the previous state to the context. - `onError` is a handler for errors that might happen when performing a mutation. Its job is to restore that previous value that we've saved to the context, because at that point, something went wrong, and our optimistic update was a wrong guess. - `onSettled` handler is called in both cases - when the mutation failed and when it succeeded. The only purpose of this handler is to request the information once more, just to make sure that we are displaying the correct data to the user. ## Final thoughts This setup might look like a lot of moving parts, but once everything clicks together it becomes a ridiculously pleasant development experience. I get a fully local-first workflow, instant reads from IndexedDB, optimistic UI out of the box, and a clean way to thread my database instance through the whole app without creating a mess of props. The best part is that nothing here is “magic.” It’s all just small, composable building blocks: a tiny wrapper around `idb`, a React context, TanStack Router loaders, and a couple of React Query hooks. And with these pieces in place, I finally feel like I’m building an app that is resilient, fast, and actually nice to work on. --- # Storage in the browser URL: https://chornonoh-vova.com/blog/storage-in-the-browser/ Date: 2025-11-08 ## Cookies Small pieces of data, sent to the browser by a server to be stored on device. They are sent with every request, therefore they have limitations: only a few hundreds of them and up to 4Kb in size. They can be automatically expired after the certain period of time. Also, not all cookies are visible in the JS: sometimes, they can be HTTP-only, which is a cool feature that adds additional security. Cookies can be set via `Set-Cookie` header, syntax looks like this: ```http Set-Cookie: =; ; ``` And browser can send cookies back in the `Cookie` header: ```http Cookie: =; = ``` Via JS, cookies can be set and retrieved via `Document.cookie` property: ```js document.cookie = "="; console.log(document.cookie); ``` It's quite hard to work with the cookies with this API, but there's newer API available: [Cookie Store API](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API) A couple of examples: ```js await cookieStore.set("some_cookie", "some_value"); console.log(await cookieStore.get("some_cookie")); for (const cookie of await cookieStore.getAll()) { console.log(`Cookie ${cookie.name}: ${cookie.value}`); } ``` Cookie Store API has an advantage of being accessible from the web workers. This new API is in the Baseline 2025, so all newer versions of browsers support it. But if you need support for older browsers, some libraries can be used, for example: [js-cookie](https://www.npmjs.com/package/js-cookie). ## Web storage API This API allows storing key value pairs as plain strings. Each origin has its own storage. There are 2 types of web storage: - Session storage is a temporary storage that is available only when the tab is open, after it closes, the session storage is destroyed - Local storage is resilient to tab closes, it preserves data between browsing sessions. But there’s an exception: incognito mode, in which local storage behaves just like session storage Web storage can only store key value pairs, just like the cookies, but have higher storage limits of up to 5MB. The values saved in the web storage are not sent with every request, like cookies. This API is synchronous, therefore not available in the web workers. Here's a couple of examples: ```js localStorage.setItem("some_item", "some_value"); console.log(localStorage.getItem("some_item")); localStorage.removeItem("some_item"); ``` I've used this API so many times, but there's quite a big drawback that I've always encountered: it's that I need to convert values to and from JSON every time. For example, I have written these kinds of hooks so many times: ```ts export function useSavedSortConfig(key: string, defaultColumn: string) { const savedSortConfig = localStorage.getItem(key); let defaultSortConfig = { column: defaultColumn, order: "desc", }; if (savedSortConfig) { try { defaultSortConfig = JSON.parse(savedSortConfig); } catch (error) { console.error(`Failed to parse saved sort config for ${key}: ${error}`); localStorage.removeItem(key); } } const [sortConfig, setSortConfig] = useState(defaultSortConfig); useEffect(() => { localStorage.setItem(key, JSON.stringify(sortConfig)); }, [key, sortConfig]); return { sortConfig, setSortConfig }; } ``` But there's an API that allows us to store more complex data, and with more convenience: Indexed DB. ## Indexed DB Indexed DB is the most advanced storage available in the browser, in my opinion. Even though, I've never worked with it, I looked at couple of examples, and it's quite amusing. In essence, it is asynchronous-first, transactional database system, just like SQL-based relational database management system. But unlike SQL, it is JS-based. All querying and management operations are performed via JS APIs. It might look intimidating at first, but it's built around a few simple ideas: 1. Database: this is an object that can contain multiple object stores 2. Object store: this is similar to the table in SQL databases, it holds records of a particular type 3. Transaction: same as a transaction in other databases, it ensures that all operations within are atomic 4. Indexes: additional data structure designed to search or filter data by a field other than the key A couple of examples: ```js const testData = [ { id: 1, text: "some example data" }, { id: 2, text: "more example data" }, ]; let db; const openReq = indexedDB.open("TestDB", 1); openReq.onerror = (event) => { console.error("Failed to open database"); }; openReq.onupgradeneeded = (event) => { const db = event.target.result; const objectStore = db.createObjectStore("test", { keyPath: "id" }); objectStore.transaction.oncomplete = (event) => { const testObjectStore = db .transaction("test", "readwrite") .objectStore("test"); for (const test of testData) { testObjectStore.add(test); } }; }; openReq.onsuccess = (event) => { db = event.target.result; db.onerror = (event) => { console.error(`Database error: ${event.target.error?.message}`); }; }; ``` In this example, database is opened, object store is created and some data is persisted into it. But as you can notice, all these callbacks can quickly become cumbersome to work with. And, thankfully, there's a nice wrapper library built to make it more usable: [idb](https://github.com/jakearchibald/idb)! Let's take a look at the example, but with this library: ```js const testData = [ { id: 1, text: "some example data" }, { id: 2, text: "more example data" }, ]; const db = await openDB("TestDB", 1, { upgrade(db, oldVersion, newVersion, transaction, event) { db.createObjectStore("test", { keyPath: "id" }); }, }); const testTransaction = db.transaction("test", "readwrite"); const results = []; for (const test of testData) { results.push(testTransaction.store.add(test)); } results.push(testTransaction.done); await Promise.all(results); ``` Much nicer! Here's how we can create a simple notes store with IndexedDB and use it in React application: ```ts const NOTES_STORE = "notes"; export function useNotesDB() { const [db, setDb] = useState(null); useEffect(() => { openDB("NotesDB", 1, { upgrade(db) { db.createObjectStore(NOTES_STORE, { keyPath: "id", autoIncrement: true, }); }, }).then(setDb); }, []); const add = async (note: Note) => { if (!db) return; await db.add(NOTES_STORE, note); }; const getAll = async (): Promise => { if (!db) return []; return db.getAll(NOTES_STORE); }; return { add, getAll }; } ``` ## Conclusion There are quite a few ways to store data in the browser: from the ancient (but still useful sometimes) cookies, to the super simple Web Storage, and up to the pretty capable IndexedDB. Each one has its own use case, quirks, and limitations. Personally, I think it’s nice that browsers give us this much choice. For quick things like saving user preferences, localStorage is usually more than enough. And, from my experience, even in production systems it's most widely used option. But if you ever need to store a lot of structured data, or make your app work offline, IndexedDB (especially with a helper library like `idb`) is the way to go. Personally, I want to try working with IndexedDB for one of my projects. So yeah, depending on what you’re building, you can always pick the right tool for the job. And it’s kinda cool how far browser storage has come since the days when cookies were all we had. --- # Exploring Drag and Drop API URL: https://chornonoh-vova.com/blog/exploring-drag-and-drop-api/ Date: 2025-11-01 I'm reading a book about the user story mapping technique. Here's it on the O’Reilly website: https://www.oreilly.com/library/view/user-story-mapping/9781491904893/ I got so inspired by it, so I've started building an application to practice this technique. And while the data model for it is not that hard, I found myself struggling with the interactions. That’s because the main interaction method that I’m thinking of is drag and drop, and I've never had an experience with it. I’ll reference the MDN documentation throughout. ## HTML Drag and Drop API There are 3 use cases for this API: - Dragging items into a page - Dragging items off the page - Dragging items inside the page For our example, we are interested in the last use case, but there are some parts that I’ll mention that will be useful for the first two use cases. There are 3 important concepts in this API as well: - Draggable element - Transfer data - Drop target Draggables are simply elements with the HTML attribute `draggable` set. For example: ```html

I can be dragged!

``` Interestingly, images and links are draggable by default; we can only disable dragging by specifying `draggable="false"`. Transfer data can be set on a drag event when they are fired. For example, when starting to drag some element, we can set some metadata so that it can be read on drop. ```html

I am draggable too!!

``` Drop targets are some elements that have an event listener for the drop event. Additionally, the drop target can be outside of the page. For example, we can drag some element on the desktop, and this way, we can also transfer some files from one page to another. ```html

I'm a drop target

``` Notice that in the drag over event handler, I’m calling `preventDefault`, because without it, the drop event won’t fire. Also, drag & drop API consists of several events: - `dragstart` - it is fired on the draggable element, when user starts dragging it - `dragleave` - it is fired on the draggable element as well, but when the dragging stops - `dragover` - it is fired on the "drop zone" element, to indicate that draggable element is hovering over it - `drop` - it is fired on the "drop zone" element as well, when user lets go of the mouse and intends to drop currently dragging element Let's combine it all together into an example! ## Plain HTML+JS implementation I was following this [MDN tutorial](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Kanban_board), but with a couple of differences. Firstly, I've defined the data model for the demo: ```js const tasks = [ { title: "To Do", tasks: [ "✏️ Write introduction for blog post", "🧠 Research best practices for drag & drop UX", ], }, { title: "In Progress", tasks: [ "💻 Implement drag & drop events in JavaScript", "🧩 Debug card reordering logic", "🧱 Build React version of the Kanban board", ], }, { title: "Done", tasks: [ "🔍 Review HTML drag & drop MDN docs", "🚀 Set up project structure and tooling", ], }, ]; ``` I've used this simple data structure for both examples. I also wrote first example as a Astro component, and it was really great to combine Tailwind and JSX, but still ending up with plain HTML and JS: ```jsx
{tasks.map(({ title, tasks }) => (

{title}

    {tasks.map((task) => (
  • {task}
  • ))}
))}
``` And here's a JS part for the first demo: ```js const columns = document.querySelectorAll(".task-column"); for (const column of columns) { column.addEventListener("dragover", (event) => { if (event.dataTransfer.types.includes("task")) { event.preventDefault(); } }); } const tasks = document.querySelectorAll(".task-item"); for (const task of tasks) { task.addEventListener("dragstart", (event) => { task.id = "dragged-task"; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("task", ""); setTimeout(() => { event.target.style.display = "none"; }, 0); }); task.addEventListener("dragend", (event) => { task.removeAttribute("id"); event.target.style.display = "block"; }); } function createPlaceholder(draggedTask) { const placeholder = draggedTask.cloneNode(true); placeholder.classList.add("task-placeholder", "border-dashed"); placeholder.removeAttribute("id"); placeholder.style.display = "block"; return placeholder; } function movePlaceholder(event) { const column = event.currentTarget; const draggedTask = document.getElementById("dragged-task"); const tasks = column.children[1]; const existingPlaceholder = column.querySelector(".task-placeholder"); if (existingPlaceholder) { const placeholderRect = existingPlaceholder.getBoundingClientRect(); if ( placeholderRect.top <= event.clientY && placeholderRect.bottom >= event.clientY ) { return; } } for (const task of tasks.children) { if (task.getBoundingClientRect().bottom >= event.clientY) { if (task === existingPlaceholder) return; existingPlaceholder?.remove(); tasks.insertBefore( existingPlaceholder ?? createPlaceholder(draggedTask), task, ); return; } } existingPlaceholder?.remove(); tasks.append(existingPlaceholder ?? createPlaceholder(draggedTask)); } for (const column of columns) { column.addEventListener("dragover", movePlaceholder); column.addEventListener("dragleave", (event) => { if (column.contains(event.relatedTarget)) return; const placeholder = column.querySelector(".task-placeholder"); placeholder?.remove(); }); column.addEventListener("drop", (event) => { event.preventDefault(); const draggedTask = document.getElementById("dragged-task"); const placeholder = column.querySelector(".task-placeholder"); if (!placeholder) return; draggedTask.remove(); column.children[1].insertBefore(draggedTask, placeholder); placeholder.remove(); }); } ``` Most notably, I've used the `cloneNode` API to create a placeholder element, and I'm also hiding an original element during the dragging by setting `display` to `none`. And, of course, the main difference is that styling is done with Tailwind instead of plain CSS. Here's a demo itself for you to play around: ## React implementation Now let’s see how we can build the same board in React, but this time, without manually wiring up all the events. I've decided to use a [formkit drag and drop library](https://drag-and-drop.formkit.com/). And while there are several drag-and-drop libraries for React, this one feels closest to the native API while handling reordering and cross-column movement out of the box. Let's take a look at the component that constitute the React implementation. The first one - is the small component for a task: ```tsx function Task({ task }: { task: string }) { return (
  • {task}
  • ); } ``` The second one - is a heart of the demo - task list with drag and drop: ```tsx function TaskList({ tasks }: { tasks: string[] }) { const [parentRef, taskList] = useDragAndDrop( tasks, { group: "kanban", dragEffectAllowed: "move", dropZoneClass: "!border-dashed", }, ); return (
      {taskList.map((task) => ( ))}
    ); } ``` The third - is a small wrapper around a task list, just to simplify building the UI: ```tsx function TaskColumn({ title, tasks }: { title: string; tasks: string[] }) { return (

    {title}

    ); } ``` Honestly, the `TaskColumn` component is not a strong requirement - `TaskList` and `TaskColumn` components can be merged together, and it'll work fine, but I prefer to make smaller components, that serve only one purpose (ideally). And the last one is the component, that brings it all together: ```tsx export function KanbanReact() { return (
    {tasks.map(({ title, tasks }) => ( ))}
    ); } ``` And that's it! I just love how the React and drag & drop library for it simplifies this hard-to-grasp API and makes everything declarative and easy to understand. Here's a demo to play around: ## Conclusion Today we looked at the HTML Drag and Drop API, and learned how it all works. Additionally, we've explored two distinct approaches: imperative and declarative, when building the drag & drop examples. Personally, I'm glad that I've started with exploring browser API before jumping into the React library. That's because without knowing what is the base building blocks of the drag & drop UIs in the browser it's easy to take the React library as magical 😅. But now, when I know how it works under the hood, I appreciate the work that was put into the library even more. Thank you for reading! I hope it was interesting and insightful for you just like it was for me. See you next time 👋 --- # inert HTML attribute URL: https://chornonoh-vova.com/blog/inert-html-attribute/ Date: 2025-10-18 There are always elements on the page that I've developed that serve no purpose; they are purely decorative. There are multiple things that we need to account for when adding such elements to the page. Let’s go through the techniques and what problems they are solving. ## pointer-events The first thing that we want to achieve is to prevent an element from being clickable and focusable. We can do it with a simple CSS rule: ```css .example { pointer-events: none; } ``` ⬆️ Prevent the element from being clickable/focusable. There are additional capabilities of the pointer-events property; for example, you can have an SVG element receive pointer events only on the stroke or only on the fill. Read more in the [pointer-events documentation](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events). ## user-select If we’re displaying some purely decorative emojis, they should also not be selectable by the user. There is a CSS rule that exists to do that as well: ```css .example { user-select: none; } ``` ⬆️ Prevent the text from being selectable. The other interesting thing about this property is that you can set it to `all`, and users will only be able to select the element fully, not letter by letter. Read more in the [user-select documentation](https://developer.mozilla.org/en-US/docs/Web/CSS/user-select). Surprisingly, this CSS property is not in the baseline, because it’s not supported in the WebView on iOS 🫠 ## aria-hidden We also want to hide the element from the screen readers. It can be achieved with an HTML attribute aria-hidden. For example: ```html ``` ⬆️ Hides the element from the screen readers. Recently, I caused an accessibility issue with this one 🫣. It's because one of the icons had an off-screen text associated with it, and a visible label right next to it, essentially reading the icon description twice. It's a pretty common technique to hide an element description from view, but leave it in the accessibility tree so that screen readers can still present it to users who need it. TailwindCSS has the [sr-only](https://tailwindcss.com/docs/display#screen-reader-only) utility class for it. Read more in the [aria-hidden documentation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden). ## inert What if we could achieve all of the above… With one attribute? Turns out, we can do that with an inert HTML attribute. This attribute disables click and focus events, disallows selection, and hides an element from screen readers. Furthermore, this attribute has excellent browser support! ```html
    ``` Additionally, all of the child elements will inherit inert from the parent, except for ``, that kind of "escapes" this inertness. It can be made inert only by placing an inert attribute on itself. Read more in the [inert documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert) Here's a small demo with the `inert` attribute usage: Just press a button, and random emojis will get added onto the page! But you won't be able to click or select them, and they won't show up in the accessibility tree. There's also a checkbox that allows you to disable `inert`, and you'll be able to see how awful user experience becomes without it 🙃 Btw, sometimes if you click too fast, Safari makes a page zoom-in (because there's a double-tap gesture to do that). I've disabled it for this demo container. Read more in the [touch-action documentation](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action) and additionally, [touch-action utilities](https://tailwindcss.com/docs/touch-action) in TailwindCSS. ## Conclusion Sometimes, basic features of HTML are forgotten, but it is essential for a frontend engineer to know them. And of course, nobody can know everything, but just visit MDN from time to time, and I guarantee you, you'll find something useful in there 😉 --- # React performance hooks and compiler URL: https://chornonoh-vova.com/blog/react-performance-hooks-and-compiler/ Date: 2025-10-11 React recently introduced a new compiler that can automatically optimize our components. This means no more sprinkling `useMemo` and `useCallback` everywhere 🎉 (but not in all cases). Let’s see what problems it solves in practice. ## Manual optimization Firstly, here’s an example that I like to go through on an interview to gauge how knowledgeable the candidate is with React performance hooks: ```jsx function TodoList({ visibility, themeColor }) { const [todos, setTodos] = useState(initialTodos); const handleChange = (todo) => setTodos((todos) => getUpdated(todos, todo)); const filtered = todos.filter((todo) => todo.category === visibility); return (
      {filtered.map((todo, index) => ( ))}
    ); } ``` This component is pretty straightforward, and there are a couple of optimizations that we can add. ### useMemo And the first one is `useMemo`. This hook is intended for caching **data** to avoid re-computing it on every render. We can wrap the filter with it: ```js const filtered = useMemo( () => todos.filter((todo) => todo.category === visibility), [todos, visibility], ); ``` After adding it, when an unrelated piece of state changes, for example, `themeColor`, this piece of state won’t be recomputed, but returned from cache instead. It will only change when one of the dependencies changes. But that’s only the first part. To ensure that `` components aren't re-rendered unnecessarily, we also need to take care of `handleChange` function, because it’s recreated on every render! ### useCallback That’s exactly where this hook comes in: it allows us to cache **functions**. ```js const handleChange = useCallback( (todo) => setTodos((todos) => getUpdated(todos, todo)), [], ); ``` The syntax of it is essentially the same: the first argument is the function that we want to cache, and the second is an array of dependencies. In this case, it’s empty because we've used the special form of set state with a callback. ### React.memo But, turns out, it’s not enough. Even though all of the props passed to the `` component aren't changing, React is still re-rendering them. To mitigate this, the component itself needs to be memoized: ```js const TodoMemoized = React.memo(Todo); ``` `React.memo` is a higher-order component that wraps a component, and by comparing previous and new props decides whether to re-render the component or not. Here's what the manual optimization approach looks like after applying all of the optimizations: ```jsx const TodoMemoized = React.memo(Todo); function TodoList({ visibility, themeColor }) { const [todos, setTodos] = useState(initialTodos); const handleChange = useCallback( (todo) => setTodos((todos) => getUpdated(todos, todo)), [], ); const filtered = useMemo( () => todos.filter((todo) => todo.category === visibility), [todos, visibility], ); return (
      {filtered.map((todo, index) => ( ))}
    ); } ``` ## Compiler Let’s rewind to the first example. Turns out all of the optimizations we've just discussed now can be applied automatically! ![React compiler Playground showing automatic memoization transforms](../../assets/images/react-compiler-example.png) Here’s a playground with this example: [React Compiler optimized](https://playground.react.dev/#N4Igzg9grgTgxgUxALhAMygOzgFwJYSYAEAKhACYQAyeYOAFMEQG614BGeANnjgJ4AaIjgAWCALYIAwhC4QYRAL4BKIsAA6xInEJ0iAbRwUIYIWAQ4ylMAF0iAXiJRzAZRwBDHAnp5MvPO5cVibKANyaRNq6OEQi7pjkXNJxmADmCA7Cxg4AfETmlsZg9EbWuUTpOACqAA7kngjkJUVCpRDKYRFRmHpo3F4wjZltYAB0fVwDzZTlbaNwDanyfA72jqxgHNy8fJ2YXYM4sMT0XZEAPOR4zDlnkUTnUFy3Wvf3wBMDjaPi7jX00wgQl85AQAA9VPY8qdXm97udgkQANYIPj2YAg8GKLKUdFtbGEKQpdLolKJZLxdLYgD0Lzh9w6ijuF2pTzpcPOAEFyOREQVgmB0fyitjRBJpLJ5HixJIZHIYDT2SyrjcuntFCABCAdJg+qkUCA8OIavIYvwahkmAAFLhQVK+ADyNXwuiURDQMAg4iIAHJ2O52AguABaGq2+2YYODdy4YM6Y3cBAwalXOg+8L7TCMLrU6nxmrcTwETAAWQoCGQRHUIECXGrmmxYCLYD6CDARBtdsdzuLYDCmvAIggAHcAJKYAaYQJgFBoacIRRAA) I appreciate how effortless the setup was: I just had to install the Babel plugin and enable it in the configuration. I've also added updated ESLint rules for React hooks (which now include additional recommendations). This is a remarkable achievement; now the code looks a lot more like the developer intended - without all of the intricate details of memoization. But also, manual optimizations are still needed in some cases. I've encountered it when I've enabled the compiler for visualizations on my website. Even though I've removed a couple of `useMemo` usages, I still had to leave the `useCallback`s that were needed for maze generation. That’s because some functions were called inside of the useEffect. The React compiler doesn't make performance hooks obsolete — but it lets us focus more on logic, less on micromanaging renders. ## Conclusion I'm excited to install and try out this compiler on my project at work! Given how easy the setup was for my blog, I’m hoping that it will be as easy on the "real" project. The project at work is far more complex and much bigger, but I'll share how it goes. ![React 17 to 18 PR size](../../assets/images/react-17-to-18-pr-size.png) But only after I merge this monstrous PR to upgrade from React 17 to 18 😅 --- # Git Worktrees URL: https://chornonoh-vova.com/blog/git-worktrees/ Date: 2025-10-04 I'm using Git every day. I'm using it so much, that I can type some commands without even thinking. For example `git status`, `git pull`, `git add`. But there was always one limitation that I quite disliked: only one branch checked out at a time. It became even more apparent when there's a new release on the horizon, when it's sometimes required to make the same change on the `develop` and `release-x.x.x` branches at the same time. When the staging area is empty, it's not a problem, but when there are some changes on a branch that I don't want to commit, switching between branches becomes problematic. And at first, I was utilizing `git stash` to save my uncommitted work before switching to another branch. Actually, I've already used it before, when doing `git rebase`, because Git can't continue rebasing when there are some files in a "dirty state". But managing multiple stashes in a repository became too cumbersome, and I can't tell you how many times I just straight up lost my work because I just overwrote a stash. ## Worktree I happened to stumble upon this [article](https://mskadu.medium.com/mastering-git-worktree-a-developers-guide-to-multiple-working-directories-c30f834f79a5), and, without a joke, Git worktrees straight up changed my life! Let me briefly walk you through what worktree is and how I'm utilizing them. When you clone a repository to your local machine, you already get a primary working tree that points at whatever branch you checked out (often `main`, but it can be any branch). Git also lets you create additional working trees linked to the same repository, which in turn allows multiple branches to be checked out at the same time. The command to create an additional worktree is simple: ```bash git worktree add ``` For example, I have multiple releases checked out on my local filesystem: ```bash git worktree add ../project-release-x.x.x release-x.x.x ``` And by doing it this way, I'm able to quickly open release that I need in my editor or IDE and apply some fixes, or just look around. It's really useful to have a copy of the source code for a particular release, especially when I'm working on the legacy code. Worktrees are not limited to the release branches for me, though. Sometimes, albeit rarely, I can also create a worktree for a couple of different features/fixes that I work on in parallel. For example: ```bash git worktree add ../project-awesome-feature feature/project-awesome-feature ``` To not lose track of all of the different worktrees that I have, there's a command available to list all of them: ```bash git worktree list ``` And it looks something like this: ```txt /path/to/the/project 06e50f4bd [feature/awesome-new-feature] /path/to/the/project-release-7.3.0 3a295b9dd (detached HEAD) /path/to/the/project-release-7.5.5 859984b85 [release-7.5.5] /path/to/the/project-release-8.4.0 2afbe785a [fix/some-release-hotfix] ``` As you can see, each and every worktree has some different state, while on the main worktree I continue to work on new features/fixes. Deleting a worktree is really simple: ```bash git worktree remove ``` Where `` is the name of the folder where worktree was placed to. Usually I do that after the release, or when I stop working on the support ticket that requires me to take a look into the legacy system at some older release. Sometimes though, when I have some uncommitted files in the additional worktree, it is required to use `--force` flag when removing it. Also, the main worktree cannot be removed. There are additional commands available, for example `lock`, `move`, `repair`, and `unlock`. But in my day-to-day work, I've never needed them. You can read about them in the [documentation](https://git-scm.com/docs/git-worktree). ## Conclusion Since I discovered worktrees, I can’t imagine going back. They make it effortless to juggle multiple branches, fix hot issues without stashing, and keep different releases side by side. If you ever find yourself fighting with stashes or switching branches too often, give `git worktree` a try. It might just change your workflow as much as it did mine. --- # Maze generation algorithms URL: https://chornonoh-vova.com/blog/maze-generation-algorithms/ Date: 2025-09-27 I was looking for an inspiration to build something fun. I've decided to build a mini-game in which players goal is to find an exit from a maze. Of course, I can draw the maze myself, but where's the fun in that? For the game to be interesting, maze needs to be generated dynamically. So I started investigating exactly that. Turns out, it's kind of a rabbit hole, with multiple approaches to solving this problem algorithmically. ## Prerequisites But first, let's define our problem more exactly: 1. Every algorithm must return a matrix with the given width and height 2. Every element of the resulting matrix must be either a `#` or ` ` symbol (representing a wall and an empty space, respectively) 3. There should be two entrances: - one at the top left corner (starting point of the game in future) - one at the bottom right corner (destination point) 4. Width and height of the resulting matrix should be odd numbers (that's because at the beginning many algorithms have cells that are separated by walls) Here's an example of matrix with 5 rows and 7 columns, which shows, why width and height parameters need to be odd: Essentially, it just ensures that every cell must be surrounded by 4 walls. Here's a type definitions that'll be used throughout the article: ```ts type Cell = "#" | " "; type Maze = Cell[][]; type Position = { x: number; y: number; }; ``` And, right out of the gate, I've also created a couple of constants and utility functions: ```ts const DIRECTIONS = [ [0, -2], [2, 0], [0, 2], [-2, 0], ]; const TILE_SIZE = 12; function createMazeInitial(width: number, height: number): Maze { return Array.from({ length: height }, () => { return new Array(width).fill("#"); }); } function getNeighbors( pos: Position, width: number, height: number, ): Position[] { const neighbors = []; for (const [dx, dy] of DIRECTIONS) { const nx = pos.x + dx; const ny = pos.y + dy; if (nx >= 1 && nx < width - 1 && ny >= 1 && ny < height - 1) { neighbors.push({ x: nx, y: ny }); } } return neighbors; } ``` You should already be familiar with the `DIRECTIONS` array trick, that allows to quickly get all of the neighbors in a matrix, instead of repeating the same code 4 (or sometimes 8) times. This time, it's modified a little bit, because for every cell neighbors are 2 positions away in every direction. The `createMazeInitial` is just creating a matrix with a given width and height, and fills it out with all `#` (walls). The `getNeighbors` is pretty handy function that returns all neighbors for a cell, and handles out of bounds to avoid repeating the same conditions in multiple places. With all of the requirements and some common functions done, let's start with the first algorithm. ## Randomized DFS algorithm This algorithm is a randomized version of the depth-first search. The main idea is the same: pick a cell, visit neighbors (that haven't been visited already). But neighbors to visit next are picked randomly (instead of the same order as in classic DFS). Then, algorithm removes a cell between a current and the next one, and moves over to it. The process then continues, with cells that have no unvisited neighbors effectively, forming a dead-end. When algorithm encounters a dead-end, though, it needs to backtrack to a cell that still has the neighbors that can be visited. At that point, new junction will be generated, and the process will continue in a loop. The algorithm can be implemented recursively, but this time, I wanted to try something a bit different, and try to implement it iteratively. Especially, because this algorithm tends to have a very large depth of recursion. I'll still be using a stack, but instead of it being implicit (recursive function calls), it's an explicit management of the stack that differs iterative implementation from the recursive one. Here's the first algorithm implementation: ```ts function generateMazeDFS(width: number, height: number): Maze { const maze = createMazeInitial(width, height); const visited = Array.from({ length: height }, () => new Array(width).fill(false), ); const stack: Position[] = []; stack.push({ x: 1, y: 1 }); visited[1][1] = true; maze[1][1] = " "; while (stack.length > 0) { const curr = stack.at(-1)!; const neighbors = getNeighbors(curr, width, height).filter( ({ x, y }) => !visited[y][x], ); if (neighbors.length > 0) { const next = neighbors[Math.floor(Math.random() * neighbors.length)]; const wallX = (curr.x + next.x) / 2; const wallY = (curr.y + next.y) / 2; maze[wallY][wallX] = " "; maze[next.y][next.x] = " "; visited[next.y][next.x] = true; stack.push(next); } else { stack.pop(); } } maze[1][0] = " "; maze[height - 2][width - 1] = " "; return maze; } ``` And, here's a visualization for it, where you can click "Generate" button multiple times, and, because algorithm is random, new distinct maze will be generated every time! ## Randomized Kruskal's algorithm The second algorithm is also a randomized version of the Kruskal's algorithm. The original algorithm is designed for finding a minimum spanning tree in a graph. It relies on a disjoint-set data structure, and it sounds really scary, but it's incredibly easy to implement. Here's a version of it that I've written for this algorithm: ```ts class UnionFind { parent: Map = new Map(); find(x: string): string { if (!this.parent.has(x)) { this.parent.set(x, x); } if (this.parent.get(x) !== x) { this.parent.set(x, this.find(this.parent.get(x)!)); } return this.parent.get(x)!; } union(x: string, y: string): boolean { const rootX = this.find(x); const rootY = this.find(y); if (rootX === rootY) return false; this.parent.set(rootX, rootY); return true; } } ``` The goal of this data structure is to store a collection of non-overlapping sets, and for the keys of the cells I've used a simple string concatenation: ```ts function posKey(pos: [number, number]): string; function posKey(pos: Position): string; function posKey(pos: [number, number] | Position): string { let x, y; if (Array.isArray(pos)) { [x, y] = pos; } else { x = pos.x; y = pos.y; } return `${x}|${y}`; } ``` If you're wondering why there's 3 functions written here, it's actually how overloads needs to be written in TypeScript. And for simplicity, I've declared here 2 overloads that take either an array of 2 points or a `Position` object. The algorithm works as follows: - create list of all walls - for each wall (in a random order) - if the cells were divided by a wall, remove it, and join the sets Here's an implementation of the algorithm: ```ts function generateMazeKruskals(width: number, height: number): Maze { const maze = createMazeInitial(width, height); const uf = new UnionFind(); const edges: { from: Position; to: Position; wall: Position }[] = []; for (let y = 1; y < height - 1; y += 2) { for (let x = 1; x < width - 1; x += 2) { maze[y][x] = " "; uf.find(posKey([x, y])); if (x + 2 < width - 1) { edges.push({ from: { x, y }, to: { x: x + 2, y }, wall: { x: x + 1, y }, }); } if (y + 2 < height - 1) { edges.push({ from: { x, y }, to: { x, y: y + 2 }, wall: { x, y: y + 1 }, }); } } } for (let i = edges.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [edges[i], edges[j]] = [edges[j], edges[i]]; } for (const edge of edges) { if (uf.union(posKey(edge.from), posKey(edge.to))) { maze[edge.wall.y][edge.wall.x] = " "; maze[edge.to.y][edge.to.x] = " "; } } maze[1][0] = " "; maze[height - 2][width - 1] = " "; return maze; } ``` `UnionFind` data structure becomes a heart of this algorithm, and with it, it's so easy to implement! Here's a visualization for this algorithm as well: ## Wilson's algorithm Before we move on to the last algorithm for today, I want you to take a closer look at mazes generated by the two previous algorithms. Notice something? Sometimes it's not obvious at first glance, but two previous algorithms have some _bias_. Mazes generated with randomized DFS tend to have **long corridors**. And mazes, generated with randomized Kruskal's algorithm, tend to have **many short dead-ends**. The Wilson's algorithm, is unique in a sense, that it's _unbiased_. Every possible maze that exists is generated with a same probability. The technique that allow to do that is called loop-erased random walk. Here's how it works: - begin with a random cell - then we make a path with every new cell chosen randomly, and - if we reached the maze, we add this path to a maze - but if we crossed our current path (forming a loop) - loop is erased before continuing - and we just repeat the steps above until all cells have been filled Here's how to implement it: ```ts function generateMazeWilsons(width: number, height: number): Maze { const maze = createMazeInitial(width, height); const inMaze = new Set(); const cells: Position[] = []; for (let y = 1; y < height - 1; y += 2) { for (let x = 1; x < width - 1; x += 2) { cells.push({ x, y }); } } const start = cells[Math.floor(Math.random() * cells.length)]; inMaze.add(posKey(start)); maze[start.y][start.x] = " "; for (const cell of cells) { if (inMaze.has(posKey(cell))) continue; const path = new Map(); let curr = cell; while (!inMaze.has(posKey(curr))) { const neighbors = getNeighbors(curr, width, height); const next = neighbors[Math.floor(Math.random() * neighbors.length)]; const currKey = posKey(curr); if (path.has(currKey)) { const keysToRemove: string[] = []; let found = false; for (const key of path.keys()) { if (found) { keysToRemove.push(key); } if (key === currKey) { found = true; } } for (const key of keysToRemove) { path.delete(key); } } path.set(currKey, next); curr = next; } curr = cell; while (path.has(posKey(curr))) { const currKey = posKey(curr); const next = path.get(currKey)!; maze[curr.y][curr.x] = " "; const wallX = (curr.x + next.x) / 2; const wallY = (curr.y + next.y) / 2; maze[wallY][wallX] = " "; inMaze.add(currKey); curr = next; } } maze[1][0] = " "; maze[height - 2][width - 1] = " "; return maze; } ``` And here's a visualization of this algorithm: ## Conclusion Turns out, maze generation is a pretty interesting algorithmic task, and I'm glad that I challenged myself to research it. Because along the way, I learned the unexpected application of some "classic" algorithms, cool data structure, and completely new algorithm and technique for me. And I had a lot of fun writing these visualizations as well! Combining React and canvas rendering is something that I've never done in my day-to-day job. Here’s a quick comparison of the three algorithms we explored: | Algorithm | Speed | Maze Bias | Notes | | -------------- | ------ | -------------------- | -------------------------------------------------------------------------------------------- | | Randomized DFS | Fast | Long corridors | Simple stack-based backtracking. Great for quick mazes with a natural “cave-like” feel. | | Kruskal’s | Medium | Many short dead ends | Graph-based approach using union-find. Produces dense mazes with lots of branches. | | Wilson’s | Slower | Very uniform | Uses loop-erased random walks. Ensures unbiased coverage, good for evenly distributed mazes. | These algorithms are not just academic exercises. They show up in games (procedural dungeon generation), puzzle design, and even AI testing environments where a variety of map structures are important. If you’ve followed along, you now have three different maze generators in your toolkit, plus a visualization component to see them in action. From here, you can experiment further: add weighted randomness, generate mazes in 3D, or combine algorithms to create unique results. --- # Blog updates URL: https://chornonoh-vova.com/blog/blog-updates-1/ Date: 2025-09-20 ## Newsletter Along with the previous post, I’ve created an email newsletter. I’ll use it to notify about new posts on the blog, so you don’t need to keep checking manually. Just drop your email in and you’ll know when something new is up. Setting it up wasn’t as straightforward as I expected. I had to fight with DNS records quite a bit 😅. It was a fun rabbit hole though, and I’ll definitely write a separate post about the details and gotchas. Hopefully, it’ll save someone else the same headache. For now, if you want to follow along with my writing, the newsletter is the easiest way to stay updated. ## About I’ve also created a new [About](/about) page. You can find it in the header. Right now it’s a short version of who I am and what I do, but I’m planning to refine it with more details, stories, and maybe even some fun facts. It felt important to have a proper space where new readers can quickly get a sense of the person behind the posts. Blogging feels a bit more personal this way. ## Projects Additionally, there’s now a [Projects](/projects) page that lists all of the side projects I’ve built (or am currently tinkering with). You can check it out in the header as well. I always enjoy reading about what other people are building, so I thought it would be nice to share my own experiments in one place. Some of them are small, some are ongoing, and some are just for fun. But together they give a better picture of what I like working on outside of my day job. ## Refactoring Furthermore, I've refactored a couple of the components that I've built for various visualizations, and separated the common components. Now, all visualizations should feel more consistent, but there's definitely room for improvement as well. I'm thinking about replacing some of the common components with [shadcn/ui](https://ui.shadcn.com/), because, quite frankly, I'm not so great at design, and these components look a lot more consistent. Honestly, I can go on quite a rant about different approaches there, and maybe sometime I will 😀 ## What’s next This is just the beginning. I want the website to grow along with my writing and projects. Expect more posts, more tinkering, and definitely more lessons learned along the way. If you spot anything odd or have feedback, I’d love to hear from you. And of course don’t forget to subscribe to the newsletter 😉 --- # Levenshtein distance algorithm URL: https://chornonoh-vova.com/blog/levenshtein-distance-algorithm/ Date: 2025-09-13 This week, a crazy supply-chain attack on multiple NPM packages happened. It was so massive, targeting the most popular packages, that it is estimated to be downloaded 1 billion times per week! Thankfully, the hack was identified quickly, and new, malware-free versions were published. If you are using these packages, I urge you to double-check whether you are affected or not. Here’s an excellent, detailed write-up on this matter: [Anatomy of a Billion-Download NPM Supply-Chain Attack](https://jdstaerk.substack.com/p/we-just-found-malicious-code-in-the) ## How it's related You might be confused by the title of the blog post, and say: “Why does it have some algorithm in the title??” And I’ll tell you why: hackers were not inserting random Ethereum addresses into a confirmation field. They were relying heavily on human behavior: we tend to skim over a large string of digits & characters, and decide whether it’s “looks okay”. That’s where a terrifying reality hits: hackers used the Levenshtein distance algorithm to pick the most visually similar address in an attempt to fool users who aren’t paying enough attention to the exact address. ## Legitimate use cases Of course, this algorithm wasn’t developed for this use case. The most popular, and thankfully, positive application of it is typo correction. Ever wondered how the writing software can pinpoint typos and suggest corrections? It’s just taking a large dictionary and calculating the Levenshtein distance between words; it can identify the closest one and suggest it to you. One more good example of usage is fuzzy searching: you don’t need to type the word fully and perfectly accurately (especially if it’s not in your native language) to find what you are looking for. That’s what disappointed me the most in this attack: how an example of human ingenuity can be used to do terrible things. Now, let’s take a step back from this disaster and look at the algorithm in more detail and how it can be implemented. ## Definition By definition, [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) is a metric of similarity between two strings: it is the minimum number of edits required to convert one string into another. These edits consist of single-character additions, removals, or replacements. Sometimes it’s referred to as edit distance, and actually, there is [72. Edit Distance](https://leetcode.com/problems/edit-distance/) question available on LeetCode, which essentially asks to implement the Levenshtein distance algorithm. This algorithm is a classic example of dynamic programming. It can be implemented by just following the recursive formula: ## Recursive approach Here's how it can be implemented with recursive approach: ```ts function minDistance(word1: string, word2: string): number { function recursion( word1: string, word2: string, word1Index: number, word2Index: number, ): number { if (word1Index === 0) { return word2Index; } if (word2Index === 0) { return word1Index; } if (word1.charAt(word1Index - 1) === word2.charAt(word2Index - 1)) { return recursion(word1, word2, word1Index - 1, word2Index - 1); } else { let insertOperation = recursion(word1, word2, word1Index, word2Index - 1); let deleteOperation = recursion(word1, word2, word1Index - 1, word2Index); let replaceOperation = recursion( word1, word2, word1Index - 1, word2Index - 1, ); return ( Math.min(insertOperation, Math.min(deleteOperation, replaceOperation)) + 1 ); } } return recursion(word1, word2, word1.length, word2.length); } ``` The recursive calls can also be memoized to avoid redundant computations. ```ts function minDistance(word1: string, word2: string): number { let memo: (number | null)[][] = Array.from( { length: word1.length + 1 }, () => { return new Array(word2.length + 1).fill(null); }, ); function recursion( word1: string, word2: string, word1Index: number, word2Index: number, ): number { if (word1Index === 0) { return word2Index; } if (word2Index === 0) { return word1Index; } const cached = memo[word1Index][word2Index]; if (cached !== null) { return cached; } let minEditDistance = 0; if (word1[word1Index - 1] === word2[word2Index - 1]) { minEditDistance = recursion(word1, word2, word1Index - 1, word2Index - 1); } else { let insertOperation = recursion(word1, word2, word1Index, word2Index - 1); let deleteOperation = recursion(word1, word2, word1Index - 1, word2Index); let replaceOperation = recursion( word1, word2, word1Index - 1, word2Index - 1, ); minEditDistance = Math.min(insertOperation, Math.min(deleteOperation, replaceOperation)) + 1; } memo[word1Index][word2Index] = minEditDistance; return minEditDistance; } return recursion(word1, word2, word1.length, word2.length); } ``` These two approaches represent a top-down approach. But with these dynamic programming problems, I always struggled to come up with a bottom-up approach, where you can essentially build a result as you go, starting from the smallest sub-problem (two empty strings) to a full solution. ## Bottom-up approach To start, we need a 2-D matrix to store the results of computation so far, initialized to zeroes. This matrix has M + 1 rows and N + 1 columns. Where M is the length of the first word and N is the length of the second word. The first row and the first column represent an empty string, and every subsequent row/column represents the next character in the word. Therefore, we can initialize the first row and column, because the cost of adding an additional character to the empty string is the previous cost + 1. After that, we can iterate through every cell of the matrix, and apply our recursive formula on every step of the way. In my opinion, this solution is the cleanest one: ```ts function minDistance(word1: string, word2: string): number { const m = word1.length; const n = word2.length; const dp = Array.from({ length: m + 1 }, () => { return new Array(n + 1).fill(0); }); for (let i = 1; i <= m; ++i) { dp[i][0] = i; } for (let j = 1; j <= n; ++j) { dp[0][j] = j; } for (let i = 1; i <= m; ++i) { for (let j = 1; j <= n; ++j) { const substitutionCost = Number(word1[i - 1] !== word2[j - 1]); dp[i][j] = Math.min( dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + substitutionCost, ); } } return dp[m][n]; } ``` ## Visualization Here's a nice little visualizer on how this algorithm works. You can enter two words and click "Calculate" button. After that, you'll see the `dp` table from the algorithm before. ## Conclusion Levenshtein distance is one of those timeless algorithms, that are simple to explain, yet powerful in practice. Whether it’s helping us catch typos, powering fuzzy search, or (unfortunately) being misused by attackers, it’s a reminder that technology is only as good as how we choose to apply it. Hopefully this post gave you a clearer picture of both sides of the story. --- # Different line endings URL: https://chornonoh-vova.com/blog/different-line-endings/ Date: 2025-09-06 How often do you handle file uploads in your applications? Did you know that very subtle bugs might be hiding in the code that processes them? Let's take a look at why. ## Types of line endings At the lowest level, every file is just a sequence of bytes. We already looked at how we can encode text in my blog post about [UTF-8 encoding](/blog/utf-8-encoding). Different operating systems mark line endings differently. Here are the different ways: - `CRLF` (Carriage Return + Line Feed): The sequence `\r\n`. This is the standard for Windows and DOS operating systems. - Carriage Return (`CR`, `\r`): Moves the cursor to the beginning of the current line. - Line Feed (`LF`, `\n`): Moves the cursor down to the next line. - `LF` (Line Feed): The sequence `\n`. This is used by Unix-like systems (Linux, macOS). - `CR` (Carriage Return): The sequence `\r`. This was used by older Mac systems (pre-OS X) and some Commodore machines. ## Why they differ ### Historical reasons The use of `LF` alone was established by systems like Multics and later adopted by Unix, while `CRLF` was adopted by DOS and later inherited by Windows for compatibility with older systems and certain devices. ### Typewriter analogy Carriage Return (`\r`) moves the cursor to the beginning of the line, similar to returning a typewriter carriage to the left margin. Line Feed (`\n`) moves the paper down to the next line. Windows requires both to signal a new line on a printer, while Unix uses just the Line Feed character. ## Handling Git, as an example of a cross-platform utility, offers `core.autocrlf` setting (and `.gitattributes`) to ensure the correct behavior across systems. On the Windows systems, `LF` endings are converted to `CRLF` on checkout. In the repository, line endings are normalized to `LF`. This is pretty easy to setup: ```bash # normalize to LF in repo, convert to CRLF on checkout on Windows git config --global core.autocrlf true ``` But how can we handle that in our application code? Let's take a look at the following code snippet: ```ts const handleFileChange = async (e: React.ChangeEvent) => { if (!e.target.files || !e.target.files.length) { return; } const file = e.target.files[0]; const data = await file.text(); const lines = data .split("\n") .map((line) => line.trim()) .filter(Boolean); console.log(lines); }; ``` The logic seems to be pretty solid here - read all of the file contents, split it and have the result in the end line by line. But, this handler might not process all of the lines correctly. It will process both `CRLF` and `LF` line endings correctly, but in very rare edge cases (just like using `CR` alone) the input won't actually be split, resulting in one long line. Here's how to handle it correctly: ```ts const handleFileChange = async (e: React.ChangeEvent) => { if (!e.target.files || !e.target.files.length) { return; } const file = e.target.files[0]; const data = await file.text(); const normalized = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const lines = normalized .split("\n") .map((line) => line.trim()) .filter(Boolean); console.log(lines); }; ``` Notice, how first, the data is normalized, to remove all of the inconsistencies between different line endings, and after that, normalized data is split just like in the previous method. But this time, the problem with a single line string is avoided. The `.filter(Boolean)` that I've shown in both cases, is a neat way to filter all of the [_falsy_](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) values from an array. ### Optional: streaming If files are large, take into mind that `file.text()` reads everything into memory. For huge inputs, consider using `file.stream()`, that will read file chunk by chunk. Let's take a look at how we can implement that: ```ts function splitStream(splitOn: RegExp) { let buffer = ""; return new TransformStream({ transform(chunk, controller) { buffer += chunk; const parts = buffer.split(splitOn); parts.slice(0, -1).forEach((part) => controller.enqueue(part)); buffer = parts.at(-1); }, flush(controller) { if (buffer) controller.enqueue(buffer); }, }); } const handleFileChange = async (e: React.ChangeEvent) => { if (!e.target.files || !e.target.files.length) { return; } const file = e.target.files[0]; const stream = file .stream() .pipeThrough(new TextDecoderStream()) .pipeThrough(splitStream(/\r\n|[\n\r]/)); const lines = []; for await (const line of stream) { const trimmed = line.trim(); if (!trimmed) continue; lines.push(trimmed); } console.log(lines); }; ``` The regex in the last example is able to handle all 3 scenarios: it is treating `CRLF` as a single delimiter, and handles `LF`-only and `CR`-only lines. ## Wrapping up Line endings may seem like a small detail, but they can cause subtle bugs when dealing with uploaded files across different operating systems. I know it firsthand, unfortunately, when these subtle differences caught my code off guard 😅. To deal with these differences between files, you can choose one of two approaches that we explored today: - Normalization: transform all line endings to one common denominator. - Regex: change split logic to identify the 3 different line endings. I hope it was useful for you, at least when a file parsing bug pops up, you'll have an idea of what might have gone wrong 😉. --- # Finally block not running - war story URL: https://chornonoh-vova.com/blog/finally-not-running-war-story/ Date: 2025-08-30 This week I've encountered two strange errors that broke production, and I want to share one of them. ## Handling exceptions Let's take a look at this piece of code: ```java public class Program { public static void main(String[] args) { File temp = null; try { temp = File.createTempFile(UUID.randomUUID().toString(), ".tmp"); System.out.println("Created temp file: " + temp.getAbsolutePath()); } catch (Exception exception) { System.err.println(exception.getMessage()); } finally { if (temp != null && temp.exists()) { boolean isDeleted = temp.delete(); System.out.println("File " + temp.getAbsolutePath() + " deleted: " + isDeleted); } } } } ``` This is pretty common pattern of handling some temporary resources. At first glance, the code looks fine. But under certain conditions, the finally block responsible for deleting temporary files is never executed. In production, it led to some disastrous consequences: temp directory would completely fill up (sometimes in a span of 30 minutes!), and system no longer worked correctly. Let's look at the reasons why finally block might not execute. ### JVM Exit If the JVM (Java Virtual Machine) itself exits while the `try` or `catch` block is being executed, the `finally` block won't be executed. There can be multiple reasons why JVM exits: - Manually calling `System.exit()` - Manually calling `Runtime.getRuntime().exit()` - Fatal error within the program (for example, `OutOfMemoryError`) - Operating system might forcefully end the process ### Thread termination If the thread, that is executing `try-catch-finally` block is forcefully killed or interrupted. In the system, that I've provided an example, there were multiple threads (workers) that were executing jobs. In such multi-threaded environments, where threads can be abruptly stopped, this situation is more common. ### Problems with code itself There can be problems with a code itself, for example, infinite loops in the try or catch blocks. Naturally, this would prevent a `finally` block from executing. Another problem that can arise is with deleting the file itself: the `delete()` method of the `File` can return false if file cannot be deleted (for example, when some other process is blocking deletion). Or the `SecurityManager` in JVM might block file deletion if it's not allowed. ## How to fix it I've decided to approach this problem from two different angles: more robust and safe code that is trying to delete a file, and a fallback script, if everything else goes wrong. ### Try with resources I've implemented a helper class to hold on to temporary resource: ```java public class TempFile implements AutoCloseable { private static final int MAX_DELETE_RETRIES = 3; private static final int DELETE_RETRY_DELAY = 1000; private final File file; public TempFile(String prefix, String suffix) throws IOException { this.file = File.createTempFile(prefix, suffix); } public File getFile() { return file; } private void delete() { try { Files.deleteIfExists(this.file.toPath()); } catch (IOException e) { System.err.println("Failed to delete file " + e.getMessage()); } } @Override public void close() { int attempts = 0; while (this.file.exists() && attempts < MAX_DELETE_RETRIES) { this.delete(); if (this.file.exists()) { System.err.println("File " + this.file.getAbsolutePath() + " still exists after " + (attempts + 1) + " attempts"); try { Thread.sleep(DELETE_RETRY_DELAY); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); break; } } attempts++; } if (file.exists()) { System.err.println("Failed to delete file " + this.file.getAbsolutePath() + " after " + attempts + " attempts"); } else { System.out.println("Deleted file " + this.file.getAbsolutePath() + " after " + attempts + " attempts"); } } } ``` As you can notice, it implements `AutoCloseable`, so now, the consumer can utilize try with resources: ```java public class Program { public static void main(String[] args) { try (TempFile temp = new TempFile(UUID.randomUUID().toString(), ".tmp")) { System.out.println("Working with temp file: " + temp.getFile().getAbsolutePath()); } catch (Exception exception) { System.err.println(exception.getMessage()); } } } ``` By doing this, I've separated the business logic that needs a file and resource management. It allows the resource management part to be as complicated as it needs to, while still providing clean interface for consumers. And, as you can see, I indeed added retries to the file deletion logic. ### Fallback To be 100% sure, I've also added the cron script, that periodically checks files in the `/tmp` folder, and deletes them, if they are older than certain time period. ```bash #!/bin/bash # Cron setup # */2 * * * * /bin/cleanup_tmp.sh >> /var/log/cleanup_tmp.log 2>&1 # Removes temporary files older than 10 minutes echo "[$(date)] - cleaning up temporary files" FILES_COUNT=$(find /tmp -maxdepth 1 -name "*.tmp" -cmin +10 -delete -print | wc -l) echo "[$(date)] - $FILES_COUNT files deleted." ``` As you can see by the cron setup, script runs every 2 minutes and deletes files older than 10 minutes (this 10 minute period was chosen specifically for the system). ## Conclusion No matter how careful we are, systems will fail under real-world conditions. Our job as engineers is to reduce risk, add observability, and handle failures gracefully, not to chase an impossible “perfect” system. --- # The GitFrag challenge URL: https://chornonoh-vova.com/blog/gitfrag-challenge/ Date: 2025-08-23 This week, I decided to participate in the [GitHub hackathon](https://github.blog/open-source/for-the-love-of-code-2025/) and build something fun (as always), but this time it will involve working with the GitHub API. The idea is to build a utility to perform defragmentation (just like old-style disk defragmentation) on your profile contributions. This should look super fun visually, especially a progress animation. And, additionally, I’m thinking of implementing a couple of different sorting algorithms, so it’ll be even more fun observing how they compare to each other. Just look at how messy my contributions look out of the box! ![My messy GitHub contributions](../../assets/images/my-github-contributions.png) It would be much better if they were neatly organized 🧐 ![My GitHub contributions, sorted](../../assets/images/my-github-contributions-sorted.png) That's exactly what I'll be building! ## Day 1: Setup & Data To kick it off, let’s set up the project, do some initial scaffolding, and retrieve the data from the GraphQL API. I’ve decided to go with my old trusty Vite once more: ```bash npm create vite@latest ``` We’ll also need to install a few additional dependencies: ```bash npm install @tanstack/react-query dedent zod ``` For the UI elements, I’m trying to follow GitHub’s UI itself. When I was creating these input & UI elements, I was basically looking at UI and re-creating it. Turns out, there’s a whole slew of components available, and a design system: [Primer](https://primer.style) 🤦. I wish I knew about it when I started, but oh well, making things from scratch is fun too 😉 To request the GraphQL API, I’ve created a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), because, when requesting without it, I was rate-limited 😢 Here's how I setup the API request to get contributions: ```ts const API_URL = "https://api.github.com/graphql"; const ContributionsSchema = z.object({ data: z.object({ user: z .object({ contributionsCollection: z.object({ contributionCalendar: z.object({ months: z.array( z.object({ name: z.string(), totalWeeks: z.number(), }), ), weeks: z.array( z.object({ contributionDays: z.array( z.object({ color: z.string(), contributionCount: z.number(), }), ), }), ), }), }), }) .nullable(), }), errors: z .array( z.object({ type: z.string(), message: z.string(), }), ) .optional(), }); export type Contributions = z.infer; export function useContributions(username: string) { return useQuery(contributionsOptions(username)); } function contributionsOptions(username: string) { return queryOptions({ queryKey: ["contributions", username], queryFn: () => fetchContributions(username), }); } export async function fetchContributions( username: string, ): Promise { const query = dedent`{ user(login: "${username}") { contributionsCollection { contributionCalendar { months { name totalWeeks } weeks { contributionDays { color contributionCount } } } } } }`; const response = await fetch(API_URL, { method: "POST", headers: { Authorization: `Bearer ${import.meta.env.VITE_GITHUB_PAT}`, }, body: JSON.stringify({ query }), }); if (!response.ok) { const details = await response.json(); throw new Error( `Error fetching contributions ${response.status} ${response.statusText}`, { cause: details?.message }, ); } const raw = await response.json(); return ContributionsSchema.parse(raw); } ``` This Zod schema is wild, to be honest 😅 Now, after connecting everything, here’s the result for day 1: ![GitFrag day 1 result](../../assets/images/gitfrag-day-1-result.png) ## Day 2: Contributions Graph Today’s goal was simple on paper: render a GitHub-like contribution graph. But as always, simple things hide unexpected complexity 😅 I decided to go with plain old `
    `s and ``s and finally found a perfect use case for [CSS Subgrid](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout/Subgrid). It’s supported by most modern browsers now, and honestly, it made the layout so much cleaner! Here's a component that is rendering the graph: ```tsx export function ContributionsGraph({ months, weeks, contributionDays, }: { months: ContributionCalendarMonth[]; weeks: number; contributionDays: ContributionCalendarDay[]; }) { return (
    {months.map(({ name, totalWeeks }, index) => ( {name} ))}
    Mon Wed Fri
    {contributionDays.map(({ color, contributionCount, date }, index) => ( ))}
    ); } ``` And styles, that enable this pretty layout: ```css .contributions-graph-wrapper { border-radius: 6px; border: 1px solid #d1d9e0; padding: 12px 8px; overflow: scroll; max-height: 300px; display: grid; gap: 3px; grid-template-rows: repeat(8, 12px); } .contributions-months { font-size: 12px; line-height: 12px; font-weight: 400; display: grid; grid-template-columns: subgrid; grid-column: 2 / -1; } .contributions-days-of-week { font-size: 12px; line-height: 12px; font-weight: 400; display: grid; grid-template-rows: subgrid; grid-row: 2 / -1; } .contributions-grid { display: grid; grid-template-columns: subgrid; grid-template-rows: subgrid; grid-row: 2 / -1; grid-column: 2 / -1; } .contribution { border-radius: 2px; } ``` Without further ado, here's a result: ![GitFrag Day 2 result - unsorted](../../assets/images/gitfrag-day-2-unsorted.png) Since I had a little time left, I also implemented the first and easiest defragmentation method: the trusty ol’ bubble sort ```ts type CompareFn = (a: T, b: T) => number; export function bubbleSort(arr: T[], compareFn: CompareFn): T[] { const res = structuredClone(arr); const n = res.length; let swapped = false; for (let i = 0; i < n - 1; ++i) { swapped = false; for (let j = 0; j < n - i - 1; ++j) { if (compareFn(res[j], res[j + 1]) > 0) { [res[j], res[j + 1]] = [res[j + 1], res[j]]; swapped = true; } } if (!swapped) { break; } } return res; } ``` On top of that, I added a reset button to bring contributions back to their original, unsorted state. With one of the sorting methods implemented, it starts to look a lot more like our goal: ![GitFrag Day 2 result - sorted](../../assets/images/gitfrag-day-2-sorted.png) ## Day 3: Algorithms, Explainers & Animation A lot of progress on this day! I've implemented multiple sorting algorithms, added algorithm explainers, and added an animation playback (without controls for the moment). Let's go through algorithms, the first one is merge sort: ```ts export function mergeSort( arr: T[], compareFn: CompareFn, recordingFn: RecordingFn, ): T[] { const res = structuredClone(arr); const aux = new Array(res.length); function merge(l: number, m: number, r: number) { let x = 0; for (let i = l; i < r; ++i) { aux[x] = res[i]; x += 1; } let i = 0, j = m; let k = l, n = m - l; while (i < n || j < r) { if (j === r || (i < n && compareFn(aux[i], res[j]) <= 0)) { recordingFn(k, aux[i]); res[k] = aux[i]; i += 1; } else { recordingFn(k, res[j]); res[k] = res[j]; j += 1; } k += 1; } } for (let len = 1; len < res.length; len *= 2) { for (let lo = 0; lo < res.length - len; lo += 2 * len) { const mid = lo + len; const hi = Math.min(mid + len, res.length); merge(lo, mid, hi); } } return res; } ``` You can notice two callbacks now in the function parameters: the first one is for elements comparisons, and the other one is for steps recording. This second callback allows for remembering of the steps performed by algorithm, and playing it back to the users. Here's an implementation of quick sort: ```ts export function quickSort( arr: T[], compareFn: CompareFn, recordingFn: RecordingFn, ): T[] { const res = structuredClone(arr); function partition(l: number, r: number): number { let p = l + Math.floor(Math.random() * (r - l + 1)); [res[l], res[p]] = [res[p], res[l]]; let t = res[l], i = l, j = r + 1; while (true) { i += 1; while (i <= r && compareFn(res[i], t) < 0) { i += 1; } j -= 1; while (compareFn(res[j], t) > 0) { j -= 1; } if (i > j) { break; } recordingFn(i, res[j]); recordingFn(j, res[i]); [res[i], res[j]] = [res[j], res[i]]; } recordingFn(l, res[j]); recordingFn(j, res[l]); [res[l], res[j]] = [res[j], res[l]]; return j; } function qsort(l: number, r: number) { while (l < r) { const p = partition(l, r); if (p - l < r - p) { qsort(l, p - 1); l = p + 1; } else { qsort(p + 1, r); r = p - 1; } } } qsort(0, res.length - 1); return res; } ``` And, finally, the fastest one, counting sort: ```ts type GetterFn = (element: T) => number; export function countingSort( arr: T[], getterFn: GetterFn, recordingFn: RecordingFn, ): T[] { const res = structuredClone(arr); const map = new Map(); for (const item of res) { const key = getterFn(item); if (map.has(key)) { map.get(key)!.push(item); } else { map.set(key, [item]); } } const maximum = res.reduce((max, curr) => Math.max(max, getterFn(curr)), 0); let i = 0; for (let key = 0; key <= maximum; ++key) { if (!map.has(key)) { continue; } for (const item of map.get(key)!) { recordingFn(i, item); res[i] = item; i++; } } return res; } ``` This one is special, because instead of comparison callback, it just takes a getter callback, which allows this algorithm to group elements by key and then reconstruct a resulting array. I've written a more detailed explainers for every algorithm, that includes a time complexity, and even fun facts. Link to the app will be at the end 😉 ## Day 4: Play/Pause & GitHub OAuth integration My little challenge is almost done, and it's shaping up really nicely! Today I tackled one of the hardest parts: - GitHub OAuth integration - Deployment - Play & pause functionality Here's how it looks when you logged in, and paused animation in the middle: ![GitFrag Day 4 - logged in & paused](../../assets/images/gitfrag-day-4-paused.jpeg) OAuth integration consists of 5 endpoints that I've designed: ### 1. Small login & logout endpoints Login endpoint is one of the smallest, and the only job of it is to redirect to the GitHub, so user can log in there: ```ts export default function handler(_req: VercelRequest, res: VercelResponse) { res.redirect( `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&scope=read:user`, ); } ``` Bonus point is that application's client id is not exposed to the frontend this way. Logout endpoint is very simple as well - it only unsets the cookie. ```ts export default async function handler( _req: VercelRequest, res: VercelResponse, ) { res.setHeader("Set-Cookie", [ "gh_token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0", ]); res.status(200).json({ success: true }); } ``` ### 2. Callback endpoint When user authorizes through GitHub, they'll be redirected to this endpoint, where we need to exchange short-lived `code` for an access token. I've opted out to storing access token in HTTP-only cookie, to prevent XSS attacks. ```ts type AccessTokenResponse = { access_token: string; }; type ErrorResponse = { error: string; error_description: string; }; export default async function handler(req: VercelRequest, res: VercelResponse) { const code = req.query.code; if (!code || typeof code !== "string") { return res.status(403).json({ error: "Missing code" }); } const response = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, code, }), }); const data = (await response.json()) as AccessTokenResponse & ErrorResponse; if (data.error) { return res.status(400).json({ error: data.error_description }); } res.setHeader("Set-Cookie", [ `gh_token=${data.access_token}; Path=/; HttpOnly; Secure; SameSite=Strict`, ]); res.redirect("/"); } ``` ### 3. User & Contributions endpoints These endpoints consume an access token, and make requests to GitHub API on behalf of the user. User endpoint: ```ts export default async function handler(req: VercelRequest, res: VercelResponse) { const token = req.cookies.gh_token; if (!token) { return res.status(200).json({ authenticated: false }); } const query = dedent` { viewer { login } }`; const response = await fetch("https://api.github.com/graphql", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ query }), }); if (!response.ok) { return res.status(200).json({ authenticated: false }); } const data = (await response.json()) as any; if (data.errors) { return res.status(200).json({ authenticated: false }); } res.status(200).json({ authenticated: true, username: data.data.viewer.login, }); } ``` Contributions endpoint: ```ts export default async function handler(req: VercelRequest, res: VercelResponse) { const token = req.cookies.gh_token; if (!token) { return res.status(401).json({ error: "Not authenticated" }); } const username = req.query.username; if (!username || typeof username !== "string") { return res.status(400).json({ error: "Missing 'username' query param" }); } const query = dedent` { user(login: "${username}") { contributionsCollection { contributionCalendar { months { name totalWeeks } weeks { contributionDays { color contributionCount date } } } } } }`; const response = await fetch("https://api.github.com/graphql", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ query }), }); const data = await response.json(); res.status(200).json(data); } ``` ## Day 5: Dark theme, Logo & Polish I took a last day very easy, did a little bit of refactoring, added a dark theme (of course, I'm a developer, after all 🧑‍💻). And I designed a nice little icon, so at this point, an application is complete, and you can open it up as well: [GitFrag](https://gitfrag.vercel.app/) ![GitFrag Day 5: dark mode](../../assets/images/gitfrag-day-5-dark-mode.png) Go ahead, try it out! Your portfolio deserves a defragmentation as well! ## Conclusion This was fun little challenge. Even though I have only like 1 hour each day, I was able to plan out this little experiment, and complete it even before the deadline that I set for myself. One more challenge for me was to post about my journey every day in my social network accounts. Thank you for following me and going through this adventure with me! --- # Pokémon MCP server URL: https://chornonoh-vova.com/blog/pokemon-mcp-server/ Date: 2025-08-16 The AI landscape is evolving rapidly. It's a remarkable piece of technology, and it seems that every product wants to have some AI-powered part. My project at work is no exception, so I decided to take on an initiative and try to build something that could benefit a product. ## Enter MCP One of the biggest advancements recently is MCP. What is MCP you ask? This abbreviation stands for **Model Context Protocol**. From the technical side, it's just a standardized JSON schema. But from the non-technical side it's an important milestone in the development of AI as a whole - now every AI provider (such as OpenAI, Anthropic or Alphabet) wouldn't need to reinvent the wheel. Customers, such as you and me, also benefit from it: we receive a standard way for our tools to communicate. Just imagine all of the cool possibilities that can be achieved here! Over the weekend I built a small proof-of-concept MCP server for our project. Even though it was tiny, from my point of view, it shed a little bit of light on what can be potentially done in the future. Unfortunately, I cannot share the details of this MCP server from work, but what I can do for a blog - is to build another one! ## Pokémon API There's this nice public API called [PokeAPI](https://pokeapi.co/), that contains an enormous amount of Pokémon data, accessible via free & open-source RESTful API. Let's build an MCP server so our AI can answer some questions about Pokémon! ## Setup I built an MCP server with Node.js and TypeScript, but it can also be built with Python, Java, Kotlin, or C#. For more information, take a look at this [quickstart](https://modelcontextprotocol.io/quickstart/server). As far as dependencies go, we'll only need two: - [@modelcontextprotocol/sdk](https://www.npmjs.com/package/@modelcontextprotocol/sdk): SDK for building MCP servers with TypeScript - [zod](https://www.npmjs.com/package/zod): for schema validation Here's the basic setup for the server: ```ts randomPokemonTool, randomPokemonCallback, } from "./tools/random-pokemon.js"; const server = new McpServer({ name: "pokemon", version: "1.0.0", capabilities: { resources: {}, tools: {}, }, }); server.registerTool(pokemonTool.name, pokemonTool, pokemonCallback); server.registerTool( randomPokemonTool.name, randomPokemonTool, randomPokemonCallback, ); try { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Pokémon MCP Server running on stdio"); } catch (error) { console.error("Fatal error:", error); process.exit(1); } ``` Here, I'm setting up a basic MCP server (using stdio transport), and registering all the tools. The stdio transport is key here. It allows MCP clients like Claude Desktop to communicate with our server through standard input/output streams. This modular approach with separate tool files makes the codebase easier to maintain and extend. For this little fun project, I've created 2 tools: - `get_pokemon`, that returns an information about some Pokémon - `get_random_pokemon`, that gets a list of all Pokémon, and randomly selects and returns information about one of them ## API Client Let's look at how API client is set up. It uses the native Node.js fetch API, and a function to call Pokémon API is pretty simple: ```ts const BASE_URL = "https://pokeapi.co/api/v2/"; const USER_AGENT = "pokemon-mcp/1.0.0"; export async function pokeAPIRequest( endpoint: string, params?: Record, ): Promise { const searchParams = new URLSearchParams(params); const requestUrl = new URL(`${endpoint}?${searchParams}`, BASE_URL); console.error("PokeAPI request url:", requestUrl.toString()); const headers = { Accept: "application/json", "Content-Type": "application/json", "User-Agent": USER_AGENT, }; try { const response = await fetch(requestUrl, { method: "GET", headers, }); if (!response.ok) { throw new Error(`HTTP error: ${response.status} ${response.statusText}`); } return (await response.json()) as T; } catch (error) { console.error("Error making PokeAPI request:", error); return null; } } ``` ## Data processing Here's a utility module that requests information about a Pokémon by name, and returns formatted representation: ```ts const PokemonSchema = z.object({ id: z.number(), name: z.string(), height: z.number(), weight: z.number(), abilities: z.array( z.object({ ability: z.object({ name: z.string(), }), is_hidden: z.boolean(), }), ), moves: z.array( z.object({ move: z.object({ name: z.string(), }), }), ), stats: z.array( z.object({ base_stat: z.number(), effort: z.number(), stat: z.object({ name: z.string(), }), }), ), }); type Pokemon = z.infer; function formatAbility(name: string, hidden: boolean) { return name + (hidden ? " (hidden)" : ""); } function formatStat(name: string, stat: number) { return `${name} (${stat})`; } export function formatPokemon(pokemon: Pokemon) { return [ `Pokémon ${pokemon.name}:\n`, ` - weight: ${pokemon.weight}`, ` - height: ${pokemon.height}`, ` - abilities: ${pokemon.abilities .map((a) => formatAbility(a.ability.name, a.is_hidden)) .join(", ")}`, ` - moves: ${pokemon.moves.map((m) => m.move.name).join(", ")}`, ` - stats: ${pokemon.stats .map((s) => formatStat(s.stat.name, s.base_stat)) .join(", ")}`, ].join("\n"); } export async function getPokemon(name: string): Promise { const pokemonData = await pokeAPIRequest(`pokemon/${name}`); if (!pokemonData) { return null; } const parsedPokemon = PokemonSchema.parse(pokemonData); return formatPokemon(parsedPokemon); } ``` There I've used `zod` to parse and verify schema that I've getting from the API. Also, this schema is not exhaustive; I've specified only the fields that I'm actually using when formatting the information. Here's a utility module that gets list of all Pokémon: ```ts const PokemonListSchema = z.object({ results: z.array( z.object({ name: z.string(), }), ), }); export async function getPokemonList(): Promise { const pokemonsData = await pokeAPIRequest("pokemon", { limit: "100000", offset: "0", }); if (!pokemonsData) { return null; } const pokemonList = PokemonListSchema.parse(pokemonsData); return pokemonList.results.map((p) => p.name); } ``` Again, I'm calling an API, parsing with `zod`, and mapping over the results to return only the names of all Pokémon. I've cheated a little bit here: I've used an enormous limit in the request, just to make sure that I get all the Pokémon on one request. In production, though, it's better to implement a proper pagination and/or caching. ## Tools Let's take a look at how actual tools are implemented. Here's an implementation of the `get_pokemon` tool: ```ts const PokemonName = z.string().nonempty(); export const pokemonTool = { name: "get_pokemon", title: "Pokemon information tool", description: "Get information about a pokemon", inputSchema: { name: PokemonName }, }; export const pokemonCallback: ToolCallback<{ name: typeof PokemonName; }> = async ({ name }) => { const pokemon = await getPokemon(name); if (!pokemon) { return { content: [ { type: "text", text: "Failed to retrieve a pokemon information", }, ], }; } return { content: [ { type: "text", text: pokemon, }, ], }; }; ``` I've structured every tool module in the similar way: - exported configuration object (it contains a tool name, title, description, schemas) - exported callback function (this function will be called, when tool is invoked by the MCP client) And, finally, here's an implementation of the `get_random_pokemon` tool: ```ts export const randomPokemonTool = { name: "get_random_pokemon", title: "Get random pokemon", description: "Get information about random pokemon", }; export const randomPokemonCallback: ToolCallback<{}> = async () => { const pokemons = await getPokemonList(); if (!pokemons) { return { content: [ { type: "text", text: "Failed to retrieve a list of pokemons", }, ], }; } const randomName = pokemons[Math.floor(Math.random() * pokemons.length)]!; const pokemon = await getPokemon(randomName); if (!pokemon) { return { content: [ { type: "text", text: "Failed to retrieve a random pokemon information", }, ], }; } return { content: [ { type: "text", text: pokemon, }, ], }; }; ``` The module itself has the same structure, that I've described previously, with only difference that this tool does not require any input args, so the `inputSchema` is omitted. ## Configuring the client Now to experience magic 🪄, we need to tell the MCP client (in my case, Claude Desktop), about our MCP server. Here's the configuration for the Claude: ```json { "mcpServers": { "pokemon": { "command": "node", "args": ["/path/to/pokemon/dist/index.js"] } } } ``` Replace the path with your actual compiled JavaScript location. After restarting Claude Desktop, you should see the Pokémon tools available in the interface. Now is the perfect time to test out and ask a couple of questions about Pokémon! ## Results Here's how we can discover some random Pokémon: ![Pokémon MCP random Pokémon information](../../assets/images/pokemon-mcp-random.png) Or, we can compare stats of two Pokémon, and find out, for example, who is faster: ![Pokémon MCP Pikachu vs Ditto speed comparison](../../assets/images/pokemon-mcp-pikachu-vs-ditto.png) Or, we can ask about stats of multiple Pokémon separately, and Claude will remember the information that our tools provided, and won't need to request for it again! ![Pokémon MCP Pikachu vs Ditto height comparison](../../assets/images/pokemon-mcp-abilities.png) ## Conclusions That was a fun way to learn about MCP and gain hands-on experience building one myself. While it's simple, it can provide important lessons about how this new technology works and what possibilities it opens up. This kind of integration means AI can work with any structured data source we expose, making assistants far more capable. I hope it was fun for you too! As always, full source code for the server is available in this [repository](https://github.com/chornonoh-vova/pokemon-mcp-server). Try building your own too - you might be surprised by what becomes possible! --- # Building a wizard form URL: https://chornonoh-vova.com/blog/building-wizard-form/ Date: 2025-08-09 In my day-to-day work, forms, and especially wizard forms are one of the hardest frontend challenges. And I'm not satisfied with my current approach to building them. So I decided to kill two birds with one stone: research more modern & robust way of building wizard forms and build something useful for my wife. Well, now when I think of it, I'm actually killing three birds: I'm also writing an article about it 😜 ## Current approach Let's take a step back, and look at how current approach fails to scale. First of all, it is based on React [context](https://react.dev/learn/passing-data-deeply-with-context). And there's nothing really bad about it, it's just sometimes (especially when the shared state grows) it becomes really hard to keep it performant. Secondly, form validation is based on [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) (I know, ugly 🥲), where basically child components are validating a pieces of the whole form, and it's called from the parent component to validate the whole step before proceeding. In my opinion, this is the messiest part of the setup, and what drove me to research alternatives. ## Task ahead I'm a big fan of POC (proof-of-concept), but, unfortunately, I can't disclose the project that I'm working on. So I had to think about something fairly complicated that would cover all of the needs for my work, be something interesting, and useful. It's a tricky balance to handle! But thankfully, my wife showed me how messy her taxes calculations in Excel are, so I decided to build small utility wizard that will collect all information about trades and dividends and calculate everything that is needed automatically. This wizard will consist of four screens: 1. Setup step - On this step we’ll setup a couple of variables that will be used throughout the form. 2. Trades step - This form is essentially an array of entries, which can be dynamically added/removed. 3. Dividends step - Same as previous step, this one again is dynamic list of entries. 4. Results step - This is where everything is calculated. Let's define fields for all of the steps first. On the setup step: - name of the report - leftover from the previous year (amount of money lost when selling stocks) - target currency (currency in which all results will be displayed) - income tax (the percentage for the income, for example 18% in Ukraine) - army tax (5% in Ukraine) On the trades step, each entry: - stock name - stock currency - date bought - buying price - exchange rate for buying date - date sold - selling price - exchange rate for selling date On the dividends step, each entry: - stock name - stock currency - date paid - amount paid - exchange rate for the paid date Of course, I should say that this form is not financial advice in any way, I'm building it for personal use only, and sharing it to only show interesting technical decisions. ## Approach The approach that I've chosen is as follows: - Global state that will store the whole wizard state - Centralized validation of each step with schemas - [react-hook-form](https://react-hook-form.com/) for actual form inputs By storing our state in the global store we'll be able to easily share the data between steps, and there'll be a central place if we'll need to do something cross-steps. By centralizing validations with schemas we'll no longer have validations spread out in every component, and get rid of imperative handles. ### Schema validation In the past I had pretty limited experience with [zod](https://zod.dev/), but I didn't realise how powerful this library is! In essence, it's a library to validate data with _schemas_, but applications of it are limitless. Let's define our schemas with zod: ```typescript export const DEFAULT_TARGET_CURRENCY = "UAH"; export const DEFAULT_ARMY_TAX = 5; export const DEFAULT_INCOME_TAX = 18; const num = (val: unknown) => { if (val === "" || val === undefined) return undefined; const num = Number(val); return isNaN(num) ? undefined : num; }; export const SetupSchema = z.object({ name: z.string().nonempty(), targetCurrency: z .string() .transform((val) => (val === "" ? undefined : val)) .default(DEFAULT_TARGET_CURRENCY), leftover: z.preprocess(num, z.number().min(0).optional()).default(0), armyTax: z .preprocess(num, z.number().min(0).max(100).optional()) .default(DEFAULT_ARMY_TAX), incomeTax: z .preprocess(num, z.number().min(0).max(100).optional()) .default(DEFAULT_INCOME_TAX), }); export type SetupSchemaType = z.infer; export const TradeSchema = z .object({ stockName: z.string().trim().nonempty(), stockCurrency: z.string().trim().nonempty(), buyDate: z.iso.date(), buyPrice: z.number(), buyExchangeRate: z.number(), sellDate: z.iso.date(), sellPrice: z.number(), sellExchangeRate: z.number(), }) .refine((data) => new Date(data.buyDate) < new Date(data.sellDate), { message: "Buy date must be before sell date", path: ["buyDate"], }); export type TradeSchemaType = z.infer; export const TradesSchema = z.object({ trades: z.array(TradeSchema), }); export type TradesSchemaType = z.infer; export const DividendSchema = z.object({ stockName: z.string().trim().nonempty(), stockCurrency: z.string().trim().nonempty(), payDate: z.iso.date(), payAmount: z.number(), payExchangeRate: z.number(), }); export type DividendSchemaType = z.infer; export const DividendsSchema = z.object({ dividends: z.array(DividendSchema), }); export type DividendsSchemaType = z.infer; ``` This schema neatly describes every step form, which fields are required, what types they have and with [refine](https://zod.dev/api#refinements) schemas also allow for cross-field validations. ### Global store In the past, I also worked with several global state libraries, for example, [redux](https://redux.js.org/). Honestly, [zustand](https://github.com/pmndrs/zustand) that I picked for this task is pretty similar to redux, here's a [comparison](https://zustand.docs.pmnd.rs/getting-started/comparison). The main difference is that zustand does not require to setup a provider, which simplifies setup, so that's why I've picked it. Let's implement a global state for the wizard: ```typescript DEFAULT_ARMY_TAX, DEFAULT_INCOME_TAX, DEFAULT_TARGET_CURRENCY, type DividendsSchemaType, type SetupSchemaType, type TradesSchemaType, } from "./schema"; type TaxWizardState = { currentStep: number; setup: SetupSchemaType; trades: TradesSchemaType["trades"]; dividends: DividendsSchemaType["dividends"]; }; type TaxWizardActions = { back: () => void; goTo: (step: 0 | 1 | 2) => void; completeSetup: (newSetup: SetupSchemaType) => void; completeTrades: (newTrades: TradesSchemaType["trades"]) => void; completeDividends: (newDividends: DividendsSchemaType["dividends"]) => void; reset: () => void; }; export const useTaxWizardStore = create()( (set, _get, store) => ({ currentStep: 0, setup: { name: "", targetCurrency: DEFAULT_TARGET_CURRENCY, leftover: 0, armyTax: DEFAULT_ARMY_TAX, incomeTax: DEFAULT_INCOME_TAX, }, trades: [], dividends: [], back: () => set(({ currentStep, ...other }) => ({ ...other, currentStep: Math.max(currentStep - 1, 0), })), goTo: (step) => set(({ currentStep, ...other }) => ({ ...other, currentStep: step, })), completeSetup: (newSetup) => set(({ currentStep, ...other }) => ({ ...other, setup: newSetup, currentStep: currentStep + 1, })), completeTrades: (newTrades) => set(({ currentStep, ...other }) => ({ ...other, trades: newTrades, currentStep: currentStep + 1, })), completeDividends: (newDividends) => set(({ currentStep, ...other }) => ({ ...other, dividends: newDividends, currentStep: currentStep + 1, })), reset: () => set(store.getInitialState()), }), ); ``` In this store, I've defined a simple state for the current step, defaults for every step, and some utility functions, that will be called on every step submissions. ### react-hook-form Lastly, it's time to tie it all together with [react-hook-form](https://react-hook-form.com/), a library that I didn't have experience at all, because on my work, all of the inputs are custom, and state for them is managed manually. To be honest, it was a little bit harder and at the same time simpler to setup. While `react-hook-form` was new to me, I quickly saw the benefit: no more manually wiring up `useState` for each field. Let me show you a couple of parts of the forms: ```tsx export function SetupForm() { const { t } = useTranslation(); const setup = useTaxWizardStore((state) => state.setup); const completeSetup = useTaxWizardStore((state) => state.completeSetup); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(SetupSchema), defaultValues: setup, }); const onSubmit = (data: SetupSchemaType) => { completeSetup(data); }; return (
    {/* other inputs ... */}
    ); } ``` Everything related to the form is basically managed in the `useForm` hook. This, honestly, threw me up at first, I was wondering where the state is, and how it's submitting the data. But now I think it's much simpler than defining everything manually. The second example is with dynamic array of entries: ```tsx export function TradesForm() { const back = useTaxWizardStore((state) => state.back); const trades = useTaxWizardStore((state) => state.trades); const completeTrades = useTaxWizardStore((state) => state.completeTrades); const { register, control, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(TradesSchema), defaultValues: { trades }, }); const { fields, append, remove } = useFieldArray({ control, name: "trades", }); const onSubmit = (data: TradesSchemaType) => { completeTrades(data.trades); }; return (
      {fields.map((item, index) => (
    • {/* other form fields ... */}
    • )}
    {/* Another buttons, such as submit*/}
    ); } ``` I really enjoyed working with field arrays, to be honest. It required so much code and covering of edge cases to get multiple rows of inputs to work, and in this library - it's just built-in! That is so awesome! ## Results Here's a couple of screenshots of resulting utility Setup step: ![Setup step of the tax calculator wizard](../../assets/images/tax-calc-setup.jpg "Setup step of the wizard") Trades step: ![Trades step of the tax calculation wizard](../../assets/images/tax-calc-trades.jpg "Trades step of the wizard") Dividends step: ![Dividends step of the tax calculation wizard](../../assets/images/tax-calc-dividends.jpg "Dividends step of the wizard") Of course, I haven't included the full code examples, but they are available in this repository: [tax-calc](https://github.com/chornonoh-vova/tax-calc). Beware, it's not "production-ready" in any sense, but I think it demonstrates an overall idea on how to better structure such complicated forms. It requires a little bit of polish to be released, in my opinion. ## Conclusions In the end, this little project proved that a schema-first approach with zod, a lightweight global store like zustand, and react-hook-form can make wizard forms much cleaner and less painful to build. It started as a quick experiment for my wife’s taxes, but now I have a solid structure I can reuse at work (and maybe even improve with persistence, translations, and CSV imports down the line). --- # Trie data structure URL: https://chornonoh-vova.com/blog/trie-data-structure/ Date: 2025-08-02 Imagine you're given a large dictionary and you need to implement an auto-complete. There are a couple of operations that we need to implement: - `insert` - adds a new word to a dictionary - `search` - returns `true` if the _whole_ word is present in the dictionary - `startsWith` - returns `true` if some word in the dictionary starts with prefix Here's an example set of words in a dictionary: ```txt gone good goal god golf gold gum gun ``` What data structure would you choose? ## Arrays Surely, the title gives it away - trie. But let's build up an intuition for why that is from the ground up. The simplest thing we can do is to use just a plain array. Let's estimate a [**time complexity**](https://en.wikipedia.org/wiki/Time_complexity) of every operation: - `insert` - we can always insert a new word at the end of the array, thus giving us an _O(1)_ complexity. Of course, when inserting a new element, a resize can happen. But most of the time, the insertion will just insert it into an empty cell, this is known as _amortized constant time_. - `search` - for this operation, we need to go through the entirety of the array, comparing each word in the dictionary with our target word. Remember, that to find out if the two strings are equal, we need to compare every character. So the time complexity of this operation will be _O(N•M)_ where _N_ is a number of words in a dictionary and _M_ is the length of the target word. - `startsWith` - this operation will actually have the same time complexity as a `search`, because again, we need to go through entire array and compare every word with prefix, so _O(N•M)_ where _N_ is a number of words and _M_ is the length of the prefix.
    How can we improve a runtime of these operations? ## Hash Map Another approach that comes to mind, is to pick a hash map as our data structure. Let's see how the time complexities of operations compare: - `insert` - stays _O(1)_ - because insertion into the hash map just involves hash code calculation and insertion into the appropriate cell (there can be [hash collisions](https://en.wikipedia.org/wiki/Hash_collision), and multiple way to resolve them, but its a topic for another blog post 😉) - `search` - it can be improved to become _O(1)_!, because now we don't need to compare every word in a dictionary, we can just calculate the hash code once for a target word and look up whether our hash map has this word or not. Neat! - `startsWith` - unfortunately, though this one stays _O(N•M)_, that's because hash maps do not support partial key matches, and we still need to compare every word in a dictionary with a prefix.
    But wait, what if we store not only words, but also all the possible prefixes in the hash map as well? This approach will indeed make the `search` and `startsWith` operations _O(1)_, but now the `insert` will take _O(M)_ - where _M_ is a length of the word that we're inserting. There is a drawback, though - we've significantly inflated our memory usage. Is there an approach that we could take that will have balance in runtime performance and memory usage? ## Trie Indeed, there is. And the data structure that we can use is called [trie](https://en.wikipedia.org/wiki/Trie) (or prefix tree). This kind of tree is special in the fact, that instead of storing values in nodes, it stores only one character (and marker for the end of the word).
    In the example above, `#` symbol marks a root node, and nodes marked yellow represent the end of the word. Let's analyze time complexities of our required operations. - `insert` - to insert a new word, for example _gulf_ we need to follow nodes from the root `# -> G -> U` and then insert the remaining ones as we go. Therefore insertion complexity is _O(M)_ - `search` - the same approach is working here, we follow character-by-character from the root, returning early if we encounter a node with no children and checking if the final node that we reached is indeed marks end of the word. Time complexity is also _O(M)_. - `startsWith` - we can absolutely apply the same approach as in search, and this time we don't even need to check if we've reached the end of the word in the end! And time complexity remains _O(M)_! As you can see, we've achieved balance in all of our operations. When it's time to pick a data structure, you always need to think about a trade-offs, here’s a quick summary of how each approach compares in terms of time complexity and memory: | Data structure | `insert` | `search` | `startsWith` | Memory overhead | | --------------------------- | -------- | --------- | ------------ | ------------------ | | Array | O(1) ✅ | O(N•M) ❌ | O(N•M) ❌ | Minimal | | Hash map (words) | O(1) ✅ | O(1) ✅ | O(N•M) ❌ | Medium | | Hash map (words + prefixes) | O(M) 👍 | O(1) ✅ | O(1) ✅ | Big | | Trie | O(M) 👍 | O(M) 👍 | O(M) 👍 | From Big to Medium | Where _N_ = number of words, _M_ = average word length ## Implementation To implement trie, we first need a class that represents a node: ```typescript class TrieNode { children: Array; end: boolean; constructor() { this.children = new Array(26).fill(null); this.end = false; } } ``` This is where we decide what memory overhead we'll have - big or medium. I've chosen an array where each index will we either null or node, and it will point to next character in a word. Another option could be to have hash map where each key will be a character and value will be a pointer to the next node. With that done, we can now assemble our trie: ```typescript class Trie { #root: TrieNode; constructor() { this.#root = new TrieNode(); } insert(word: string): void { let node = this.#root; for (let i = 0; i < word.length; ++i) { const idx = word.charCodeAt(i) - 97; if (node.children[idx] === null) { node.children[idx] = new TrieNode(); } node = node.children[idx]; } node.end = true; } search(word: string): boolean { const node = this.#prefix(word); return node !== null && node.end; } startsWith(prefix: string): boolean { const node = this.#prefix(prefix); return node !== null; } #prefix(word: string): TrieNode | null { let node = this.#root; for (let i = 0; i < word.length; ++i) { const idx = word.charCodeAt(i) - 97; if (node.children[idx] === null) { return null; } node = node.children[idx]; } return node; } } ``` The `insert` method walks through the tree and inserts new nodes along the way. The `#prefix` method also walks through the tree but short-circuits when the next node is null and returns the node at the end. `search` and `startsWith` are neatly implemented with just using `#prefix` method, because, in the end the only difference in their result is to check whether we reached the end of the word or not. Try to implement it yourself on LeetCode: [208. Implement Trie (Prefix Tree)](https://leetcode.com/problems/implement-trie-prefix-tree/) ## Disadvantages Imagine, that we have only two words in our dictionary: _database_ and _document_. While these words share the first letter, all other letters are different. In this case, while still maintaining the runtime performance characteristics, trie becomes very wasteful in memory (especially if it's using full arrays in nodes). To combat this, there are a couple of compression techniques existing: - [Radix trees](https://en.wikipedia.org/wiki/Radix_tree) which implements a very simple idea - storing the entire suffix in the leaf nodes. The edges can also be labeled with sequences of characters instead of only one - which can really help if dictionary has a lot of common substrings in the middle. - Bitwise and Patricia trees - [Wikipedia](https://en.wikipedia.org/wiki/Trie#Implementation_strategies) ## Conclusion Wrapping up, while tries have a very specific use cases, I found them particularly interesting to reason about. And I hope that this blog post built up some intuition about them. So I hope that the next time when you encounter an algorithmic task of auto-complete, spell checking or even [IP routing 😮](https://en.wikipedia.org/wiki/Longest_prefix_match), you'll have one more powerful tool in your toolkit to solve it! --- # Making Sudoku game from scratch URL: https://chornonoh-vova.com/blog/making-sudoku-game/ Date: 2025-07-26 Finally, after having an idea almost three weeks ago, I can write about making a simple Sudoku game from scratch! Try it live: https://chornonoh-vova.github.io/sudoku/ It all began in this post - [Sudoku verification](/blog/sudoku-verification), where we've explored what Sudoku is and various approaches that we can take to verify if it's correct. Then we stepped it up next week - [Solving Sudoku](/blog/solving-sudoku), where we explored backtracking and utilized it to solve the Sudoku, and built a fun visualization along the way. And this week, I'll walk you through how I built a simple Sudoku game combining all the algorithms and data structures into something fun! I built this game with React for UI, TypeScript because I enjoy its type safety, and Tailwind for styling. I've deployed it on the GitHub Pages, and made it a PWA so it can be installed right into the home screens. Here's how it looks like: Sudoku game screenshot The first time you open the game, Sudoku puzzle is generated and stored into a local storage. And every time when you come back, it's loaded back right from where you stopped - with all of the initial and manually filled out cells. Of course, you can also generate new puzzle - and this time I implemented proper generation, let me walk you through how it's done. ## Puzzle generation I've decided to take the following approach: 1. Generate a fully filled out grid 2. Randomly remove one cell 3. Check if puzzle that remains has unique solution 4. If not - put a number back - and go back to step 2 5. If puzzle has unique solution, continue 6. Check if we removed enough cells (for certain difficulty) 7. If we removed enough - stop - puzzle is ready 8. If not - go back to step 2 To generate a fully filled out grid I used the same approach when solving Sudoku. That is try putting a number in a cell - and verifying that the puzzle is still valid. Otherwise, backtrack. Here's how it looks in code: ```typescript function generateSolution(): number[][] { const grid = Array.from({ length: 9 }, () => new Array(9).fill(0)); const state = new SudokuState(); function fill(row: number, col: number): boolean { if (row === 9) { return true; } const nextRow = col === 8 ? row + 1 : row; const nextCol = col === 8 ? 0 : col + 1; const nums = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); for (const num of nums) { if (state.canPlace(row, col, num)) { state.place(row, col, num); grid[row][col] = num; if (fill(nextRow, nextCol)) { return true; } state.remove(row, col, num); grid[row][col] = 0; } } return false; } fill(0, 0); return grid; } ``` This code is almost the same as the code that we wrote last week to solve a Sudoku. Biggest difference is that here all of the numbers from 1 to 9 tried randomly, and `shuffle` function helps with that: ```typescript function shuffle(nums: T[]): T[] { for (let i = nums.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [nums[i], nums[j]] = [nums[j], nums[i]]; } return nums; } ``` One more piece of the puzzle before we write a puzzle generation - we need to count a number of solutions for a puzzle instead of just solving it. Here's a modified solving routine from before that also returns a count of solutions: ```typescript function countSolutions(board: number[][]): number { let count = 0; const state = new SudokuState(); for (let row = 0; row < 9; ++row) { for (let col = 0; col < 9; ++col) { if (!board[row][col]) continue; state.place(row, col, board[row][col]); } } function solve(): boolean { for (let row = 0; row < 9; ++row) { for (let col = 0; col < 9; ++col) { if (board[row][col] === 0) { for (let num = 1; num <= 9; ++num) { if (state.canPlace(row, col, num)) { state.place(row, col, num); board[row][col] = num; solve(); state.remove(row, col, num); board[row][col] = 0; if (count > 1) return true; // early exit } } // No number can be placed — backtrack return false; } } } count++; return false; } solve(); return count; } ``` One little optimization that I did is that I return early, when encountering a second solution - there is no need to know _exactly_ how many solutions there are - it is enough to know that there is more than one. With all of the pieces in place, here's a main routine to generate a puzzle: ```typescript function generatePuzzle(grid: number[][], minClues = 32) { const puzzle = structuredClone(grid); const positions = []; for (let row = 0; row < 9; ++row) { for (let col = 0; col < 9; ++col) { positions.push([row, col]); } } shuffle(positions); for (const [row, col] of positions) { const temp = puzzle[row][col]; puzzle[row][col] = 0; const clone = structuredClone(puzzle); const solutions = countSolutions(clone); if (solutions !== 1) { puzzle[row][col] = temp; } const clueCount = puzzle.flat().filter((n) => n !== 0).length; if (clueCount <= minClues) break; } return puzzle; } ``` ## Undo and redo As you can notice from a screenshot - there are two buttons that allow you to undo your most recent moves and redo them back. It's a very simple concept - I'm sure you've seen it in another pieces of software a lot. And when implementing it, I found that linked list - data structure often criticized for its inefficiency - is perfect for this task! Let's take a look at how I implemented moves history: ```typescript class MoveNode { row: number; col: number; prevNum: number; nextNum: number; prev: MoveNode | null; next: MoveNode | null; constructor(row: number, col: number, prevNum: number, nextNum: number) { this.row = row; this.col = col; this.prevNum = prevNum; this.nextNum = nextNum; this.prev = null; this.next = null; } data() { return { row: this.row, col: this.col, prev: this.prevNum, next: this.nextNum, }; } } class MoveHistory { #head: MoveNode; #curr: MoveNode; constructor() { this.#head = new MoveNode(-1, -1, -1, -1); this.#curr = this.#head; } move(row: number, col: number, prevNum: number, nextNum: number) { const node = new MoveNode(row, col, prevNum, nextNum); this.#curr.next = null; this.#curr.next = node; node.prev = this.#curr; this.#curr = node; } canUndo(): boolean { return this.#curr !== this.#head; } undo() { const data = this.#curr.data(); if (this.#curr.prev !== null) { this.#curr = this.#curr.prev; } return { row: data.row, col: data.col, num: data.prev }; } canRedo(): boolean { return this.#curr.next !== null; } redo() { if (this.#curr.next !== null) { this.#curr = this.#curr.next; } const data = this.#curr.data(); return { row: data.row, col: data.col, num: data.next }; } } ``` The main piece here is `MoveNode` - it's your typical doubly-linked list node with some payload and references to previous and next nodes. `MoveHistory` stores a reference to the head of the list and to the current node that we are pointing at the moment. When we undo, we just move the current pointer back, and if we want to redo - we just move it forward. But when there's a new move - we insert a new node at the current position, so this way we can easily discard all of the forward moves. You can use this approach when solving this LeetCode problem: [1472. Design Browser History](https://leetcode.com/problems/design-browser-history/description/) ## Conclusion Building this Sudoku game has been a rewarding challenge. From puzzle generation to game state management and PWA deployment, every step was an opportunity to learn. I hope this inspires you to build something playful with algorithms too! 🎮 Source code for the game is available in this repository: https://github.com/chornonoh-vova/sudoku. And you can play the game by yourself here: https://chornonoh-vova.github.io/sudoku/ Fireworks displaying when winning a Sudoku game --- # Solving Sudoku URL: https://chornonoh-vova.com/blog/solving-sudoku/ Date: 2025-07-19 In this week blog post, I decided to continue the Sudoku topic from a previous week. If you haven't read it, check it out [here](/blog/sudoku-verification). But this time I decided to tackle a harder problem: how to actually solve a Sudoku. Here's a LeetCode question that inspired me to write this blog post as well: [37. Sudoku Solver](https://leetcode.com/problems/sudoku-solver/description/) ## Backtracking The main idea behind a solution is [backtracking](https://en.wikipedia.org/wiki/Backtracking). We'll iterate over all of the empty cells on the board. For every such cell we can choose a number from 1 to 9. But, when choosing number for one cell, it limits numbers that we can choose for the next cell in the same row, column, and box. After choosing, we can proceed to the next empty cell and see if it satisfies all of the constraints (remember that we need to ensure that every row, column and box contain numbers from 1 to 9 without repetition). If we cannot satisfy our constraints, revert our choice and try the next number. When we successfully filled the last empty cell - that means we've solved a Sudoku! Ok, that's our algorithm outlined pretty much. Now, let's dig deeper into implementation details. Just like in the previous blog post where we've implemented Sudoku verification, we need to store state of the Sudoku. Let's implement it with bitmasks, and we already have almost everything that we need: checking of a specific bit, setting of a specific bit. But we need to implement one more operation: removal of a bit in a number. And for that, another bitwise operation exists: [Bitwise XOR (^)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_XOR). Here's how it works: Honestly, XOR is so cool, just take a look at this article on what is possible with it: [That XOR Trick](https://florian.github.io/xor-trick/). This literally blew my mind 🤯 I've also decided to refactor a separate class for storing our Sudoku state, here's how it looks: ```typescript class SudokuState { #rows: Uint16Array; #cols: Uint16Array; #boxes: Uint16Array; constructor() { this.#rows = new Uint16Array(9); this.#cols = new Uint16Array(9); this.#boxes = new Uint16Array(9); } #boxIndex(row: number, col: number): number { return Math.trunc(row / 3) * 3 + Math.trunc(col / 3); } canPlace(row: number, col: number, num: number): boolean { const box = this.#boxIndex(row, col); const mask = 1 << (num - 1); return !( this.#rows[row] & mask || this.#cols[col] & mask || this.#boxes[box] & mask ); } place(row: number, col: number, num: number) { const box = this.#boxIndex(row, col); const mask = 1 << (num - 1); this.#rows[row] |= mask; this.#cols[col] |= mask; this.#boxes[box] |= mask; } remove(row: number, col: number, num: number) { const box = this.#boxIndex(row, col); const mask = 1 << (num - 1); this.#rows[row] ^= mask; this.#cols[col] ^= mask; this.#boxes[box] ^= mask; } } ``` Here, I've encapsulated box index calculation, and the arrays that store the state itself, and in my opinion, it turned out to be pretty nice interface. If you're wondering what all of these hashes are, I've also written a [blog post](/blog/typescript-vs-javascript-private) about it 😉 Here's how the algorithm comes together: ```typescript const EMPTY = "."; function solveSudoku(board: string[][]): void { const sudokuState = new SudokuState(); const empty: [number, number][] = []; for (let row = 0; row < 9; ++row) { for (let col = 0; col < 9; ++col) { if (board[row][col] === EMPTY) { empty.push([row, col]); continue; } sudokuState.place(row, col, parseInt(board[row][col])); } } function backtrack(index: number): boolean { if (index === empty.length) return true; const [row, col] = empty[index]; for (let num = 1; num <= 9; ++num) { if (!sudokuState.canPlace(row, col, num)) { continue; } sudokuState.place(row, col, num); board[row][col] = num.toString(); if (backtrack(index + 1)) { return true; } sudokuState.remove(row, col, num); board[row][col] = EMPTY; } return false; } backtrack(0); } ``` In the first loop, I'm filling up both the `sudokuState` and `empty` cells array. And I've utilized a neat trick here: just pay the attention that `backtrack` function is defined inside of the `solveSudoku` function, not separately. By defining it this way, there's no need to pass around `board`, `sudokuState`, and `empty` on every recursive call. I'm only passing the current `index` of the array that I'm working on in the subsequent recursive calls. That's what I'm calling [closures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures) for a rescue! ## Visualization To better understand how this backtracking works, I've decided to build visualization for it. There's a GIF available on the Wikipedia page, that I've added a link earlier, but I decided to make it more interactive and have some fun while doing it. Here it is: In this visualization, there are 3 buttons available: - Start - it will solve the Sudoku puzzle, and start a solving animation - Reset - it will just reset the Sudoku board to it's initial state - Randomize board - will randomly select one of four boards that I've added for this visualizer Two of the boards available are easy preset and other two are of medium difficulty. It's really simple to distinguish between them - easy ones have less empty cells, and medium ones have more. So, unfortunately, there's no actual Sudoku board generation, but it's on my TODO list 😉 After the puzzle is solved, each step that was performed during a algorithm execution will be shown until the board is filled completely. Now, go ahead - try it out! It's really fascinating to observe how backtracking works in action. I also want to point out that actual backtracking is more present on the medium boards. ## Closing thoughts Solving Sudoku was a fun challenge for me. But at the same time it was a great way to explore algorithms, visualization, and problem-solving. Whether you’re into puzzles or programming (or both), building your own solver is an incredibly rewarding project. I want to note, that apart from backtracking solution that we've explored today, there are more optimized approaches. Here's a couple of them: - Donald Knuth's [DLX algorithm](https://en.wikipedia.org/wiki/Dancing_Links) - [Naked Single](http://sudopedia.enjoysudoku.com/Naked_Single.html) - [Minimum Remaining Values](https://www.alooba.com/skills/concepts/data-science/minimum-remaining-values/) heuristic - and, probably, even more... Leave a reaction and your comments below, and don't hesitate to share - the more people are consumed by Sudoku addiction - the better 😈. See you next week! --- # Sudoku verification URL: https://chornonoh-vova.com/blog/sudoku-verification/ Date: 2025-07-12 Recently, I started doing some Sudoku in my not-so-frequent free time. All thanks to this amazing app that I've found, that helps me to build up this daily habit: https://sudokuaday.com/ Imagine how surprised I was that LeetCode has a couple of interesting puzzles related to this! Let's break down and solve one of them together: [36. Valid Sudoku](https://leetcode.com/problems/valid-sudoku/description/) ## Rules In its essence, Sudoku is just a `9x9` board filled with numbers according to very simple rules: 1. Every row must contain every number from 1 to 9 without repetition 2. Every column must contain every number from 1 to 9 without repetition 3. Every `3x3` box must also contain every number from 1 to 9 without repetitions In this LeetCode question, though, input boards might not be filled out completely (there can be some empty cells), but all of the rules above still apply. So the algorithm to solve this should be very simple: we need to walk through every cell of the matrix (Sudoku board) and check if the row, column and box that this cell is part of is valid. Let's write some code to solve it this way! ## First approach In this approach, I've used the Set to store numbers in rows and columns that we've seen so far, so when checking for the next number, it really boils down to looking into the correct set, and adding the number, if we haven't seen it before. ```typescript function isValidSudoku(board: string[][]): boolean { const n = board.length; const rows = Array.from({ length: n }, () => new Set()); const cols = Array.from({ length: n }, () => new Set()); const boxes = Array.from({ length: n }, () => new Set()); for (let i = 0; i < n; ++i) { for (let j = 0; j < n; ++j) { if (board[i][j] === ".") { continue; } const num = parseInt(board[i][j]); const idx = Math.trunc(i / 3) * 3 + Math.trunc(j / 3); if (rows[i].has(num) || cols[j].has(num) || boxes[idx].has(num)) { return false; } rows[i].add(num); cols[j].add(num); boxes[idx].add(num); } } return true; } ``` One more interesting thing to note here, is the calculation of the box index. We can imagine the box indexes to be located as a 3x3 grid like so: ``` 0 1 2 3 4 5 6 7 8 ``` Let's say that we want to understand, to which box cell with row = 4 and column = 7 corresponds to. ```txt rowOffset = (row / 3) * 3 = 3 ``` ```txt colOffset = column / 3 = 2 ``` ```txt boxIndex = rowOffset + colOffset = 5 ``` First part calculates offset by row, and the second part calculates offset by column, and by summing them together we get the box index. ## Optimizing with arrays If we take a closer look into a previous solution, we can see that we don't really need Set! It all really boils down to a fact, that we can only have a total of 9 possible values for a cell value, so we can optimize our solution by using just an array of fixed size and looking up directly by the number value. So here is an implementation: ```typescript function isValidSudoku(board: string[][]): boolean { const n = board.length; const rows = Array.from({ length: n }, () => new Array(n).fill(0)); const cols = Array.from({ length: n }, () => new Array(n).fill(0)); const boxes = Array.from({ length: n }, () => new Array(n).fill(0)); for (let i = 0; i < n; ++i) { for (let j = 0; j < n; ++j) { if (board[i][j] === ".") { continue; } const idx = parseInt(board[i][j]) - 1; const box = Math.trunc(i / 3) * 3 + Math.trunc(j / 3); if (!!rows[i][idx] || !!cols[j][idx] || !!boxes[box][idx]) { return false; } rows[i][idx]++; cols[j][idx]++; boxes[box][idx]++; } } return true; } ``` You can notice a couple of things: 1. I'm subtracting 1 from the parsed number value, because parsed number will be from 1 to 9 but indexes need to be from 0 to 8 2. I'm utilizing JS number to boolean conversion with `!!`, because when the count becomes 1 we need to return (same as `.has` in the first approach) After looking through it one more time for this blog post, we can improve our array approach by using array of booleans instead of arrays of numbers. It's because we need to keep track of two possible states: whether we've seen this number or not. I'll leave implementation of this idea as an exercise for a reader 😜. But I strongly advise doing so, because after implementing it, it'll be easier to understand the next optimization that we're going to do. ## Bitwise magic Most of the people will stop at this point, but not me. So I decided to research what else we can do to optimize this piece of code. And, turns out, we can! It is possible with some bit magic. I'll admit, I'm not that good with bitwise operations 🥲. Maybe it comes down to a fact that most of my time I'm writing in JS/TS, where it's not that common to do these kind of operations. Every time when I see all these smart approaches, and how we as programmers can literally squeeze out more value out from every bit, I get genuinely awed. I'll do my best when trying to explain how the next optimization works. Ok, so the main idea is to eliminate sub-arrays that we are currently using to check for presence of number in row/column/box with a single number. While JavaScript number are typically 64-bits floating-point values, the bitwise operations convert them to 32-bit signed integers, so we're going to assume that we're working with 32 bit numbers. And since we need to only store presence/absence of an particular number in the row/column/box, we actually really need only 9 bits of space for every row/column/box! Let's take a look at some example state: In this example, bit #1, #3, #7 and #8 are set. So just by comparing these specific bits we can deduce, whether we have a number in state or not. Let's see how we can check if specific bit is set and how we can actually set the bit. First of all, we need to convert an index that we want to check to a bit mask by performing [left shift (<<)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Left_shift). For example: - `1 << 4` becomes `1000` (in binary) - `1 << 3` becomes `100` (in binary) Then, we can perform the [bitwise AND (&)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND) on our state and mask to check is specific bit is set: If we want to set the specific bit, we can use the same mask and perform the [bitwise OR (|)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_OR) on our state: That's pretty much it, here's how it translates to code: ```typescript function isValidSudoku(board: string[][]): boolean { const n = board.length; const rows = new Uint16Array(n); const cols = new Uint16Array(n); const boxes = new Uint16Array(n); for (let i = 0; i < n; ++i) { for (let j = 0; j < n; ++j) { if (board[i][j] === ".") { continue; } const idx = parseInt(board[i][j]) - 1; const box = Math.trunc(i / 3) * 3 + Math.trunc(j / 3); const mask = 1 << idx; if (rows[i] & mask || cols[j] & mask || boxes[box] & mask) { return false; } rows[i] |= mask; cols[j] |= mask; boxes[box] |= mask; } } return true; } ``` Oh, and also I've used recently introduced [Uint16Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint16Array) instead of just array of numbers to save on a space a little bit (because our 9-bit state requirement still fits within 16 bits). ## Conclusion Who knew that playing Sudoku in my free time would lead me down a rabbit hole of bitwise tricks and set logic? It’s always fun when a casual hobby meets the world of algorithms, and now I can say that Sudoku helped me write cleaner, faster code! This was just a taste of how we can use classic problems to explore different approaches. In future posts, I might explore how to solve full Sudoku boards using backtracking and constraint propagation. Stay tuned! --- # UTF-8 encoding URL: https://chornonoh-vova.com/blog/utf-8-encoding/ Date: 2025-07-05 Everything inside the computer is represented as a combination of 0s and 1s. But we, humans, don’t really think in 1s and 0s. Even right now, when I’m writing this blog post, my computer is doing a hard work for me by converting some 1s and 0s and displaying them to me as text. But how are computers doing that? This is a question that I never really asked myself in my early years as a software engineer, but the [Performance Engineering](https://www.csosvita.com/courses/performance-engineering) course from CSOsvita really opened my eyes to that topic. It comes down to one truth: we cannot view any arbitrary sequence of bits; we also need to know how it’s encoded. Let’s take a look at how UTF-8 encoding works, the most [popular](https://en.wikipedia.org/wiki/Popularity_of_text_encodings) text encoding on the internet. ## How it works First of all, UTF-8 operates with _code points_. Code point is just a number, assigned to a character in Unicode. For example, the character "a" has a code point of **U+0061**. UTF-8 then defines how those code points are converted to/from binary representation. UTF-8 is a variable-length encoding, that is one character can take up from 1 to 4 bytes. Here's how code points are converted to UTF-8
    | First code point | Last code point | Byte 1 | Byte 2 | Byte 3 | Byte 4 | | ---------------- | --------------- | ---------- | ---------- | ---------- | ---------- | | **U+0000** | **U+007F** | `0xxxxxxx` | | | | | **U+0080** | **U+07FF** | `110xxxxx` | `10xxxxxx` | | | | **U+0800** | **U+FFFF** | `1110xxxx` | `10xxxxxx` | `10xxxxxx` | | | **U+01000** | **U+10FFFF** | `11110xxx` | `10xxxxxx` | `10xxxxxx` | `10xxxxxx` |
    One of the cool features of the UTF-8 is that ASCII characters are encoded exactly the same. And, additionally, it takes up less space than UTF-16 or UTF-32 which require 16 or 32 bits to encode each character, respectively. There's a catch, though, UTF-16 is also a variable-length encoding, so some characters can take up to 4 bytes. But UTF-32 is always 4 bytes. Here are a couple of example characters and how they'll be encoded in UTF-8: - Character "a" (U+0061) is encoded in one byte as `0x61` - Character "ŋ" (U+014B) is encoded in two bytes as `0xc58b` - Character "დ" (U+10D3) is encoded in three bytes as `0xe18393` - Emoji "😂" (U+1F602) is encoded in four bytes as `0xf09f9882` ## Visualization And we should actually start with an interesting fact: JavaScript (just like Java) uses UTF-16 to encode strings in runtime. This was a bit of surprise to me. I always thought that JS uses UTF-8. There are languages that use UTF-8 for string encoding, for example, Rust. So the task of visualizing UTF-8 encoding with JS actually comes down to converting UTF-16 to UTF-8. I will go down that rabbit hole in some future post, and try to implement it from scratch. But for now, let's concentrate on UTF-8, and to do that, we'll utilize this built-in API in JS: [TextEncoder](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder). Here's a core of what's powering this table: ```typescript const map = useMemo(() => { const radix = repr === "hex" ? 16 : 2; const pad = repr === "hex" ? 2 : 8; const textEncoder = new TextEncoder(); const result: [string, string[]][] = []; for (const ch of input) { const encoded = textEncoder.encode(ch); const bytes = Array.from(encoded).map((b) => b.toString(radix).padStart(pad, "0"), ); result.push([ch, bytes]); } return result; }, [input, repr]); ``` Here, I'm iterating over every character in a string, and encoding it into [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array), with the help of TextEncoder, and then mapping over every byte and transforming it into hexadecimal or binary string. I myself only recently learned about the TextEncoder API, and got to use it in practice for this blog post. ## Experiments Now, you can try inserting this family emoji into the input text box 🧑‍🧑‍🧒‍🧒, and observe the result. Turns out, this emoji is actually composed of multiple code points: 1. 🧑 (U+1F9D1) 2. 'ZERO WIDTH JOINER' (U+200D) 3. 🧑 (U+1F9D1) 4. 'ZERO WIDTH JOINER' (U+200D) 5. 🧒 (U+1F9D2) 6. 'ZERO WIDTH JOINER' (U+200D) 7. 🧒 (U+1F9D2) Those "ZERO WIDTH JOINER" code points will be displayed as an empty space in the table, because this code point is only needed to combine multiple code points into one. You can try inputting the following example as well: র‍্য. - ZWJ is also used here to combine several characters together. - it contains ্('BENGALI SIGN VIRAMA' (U+09CD)). This character does not make sense by itself, but it can only be used in conjunction with another character to add this little sign at the bottom. One more interesting thing that I found, is that when you enter the "heart" emoji ❤️, it actually is a combination of two code points: - Unicode Character 'HEAVY BLACK HEART' (U+2764) - Unicode Character 'VARIATION SELECTOR-16' (U+FE0F) Try to find something interesting as well! ## Conclusion UTF-8 is remarkable when you think about it. It keeps things simple for languages like English, while also giving us the power to represent every character from every language, and even emojis! It’s no wonder it became the go-to encoding for the web. In my opinion, for every programmer out there, knowing how UTF-8 works is essential. It’s one of those fundamental technologies that without a doubt, runs out world. --- # Warehouse simulator from scratch: Part 2 URL: https://chornonoh-vova.com/blog/warehouse-simulator-part-2/ Date: 2025-06-28 > This post is a part 2 of the miniseries. Read part 1 [here](/blog/warehouse-simulator-part-1). Part 2 introduces wide containers — two-cell entities rather than single boxes — which means the movement logic gets significantly more complex. ## Puzzle, part 2 Part 2 of the puzzle is very similar to the part one with a key difference: now everything on the map is twice as wide (except of the robot). So, that: - `#` becomes `##` - `O` becomes `[]` (instead of two boxes, there will be one wide container) - `.` becomes `..` - And, finally, `@` stays the same, but with added padding so that map layout stays correct `@.` Here's how our example map will look like: ```typescript export const exampleMap = `#################### ##....[]....[]..[]## ##............[]..## ##..[][]....[]..[]## ##....[]@.....[]..## ##[]##....[]......## ##[]....[]....[]..## ##..[][]..[]..[][]## ##........[]......## ####################`; ``` To support containers I've introduced new type `Container`: ```typescript type Container = { left: Position; right: Position; }; ``` Containers are different from the boxes, and to represent them, we need to have two positions instead of one: left and right. This will be crucial when implementing movements, because we will need to make sure that we maintain integrity of them. In simpler words, we can't move left part of the container and leave the right part at the same place, and vice versa. And I've updated `Warehouse` data structure to store the containers appropriately: ```typescript export class Warehouse { // ... containersLeft: Map; containersRight: Map; // ... } ``` This time, I've used JS Map instead of Set, and you'll see why later, when I'll be implementing movements. Updated parsing: ```typescript export class Warehouse { // ... constructor(map: string) { // ... this.containersLeft = new Map(); this.containersRight = new Map(); // ... for (let row = 0; row < this.height; ++row) { for (let col = 0; col < this.width; ++col) { const tile = tiles[row][col]; const position = { row, col }; const positionHash = getPositionHash(position); switch (tile) { // ... case "[": { const right = { row, col: col + 1 }; const rightHash = getPositionHash(right); const container = { left: position, right }; this.containersLeft.set(positionHash, container); this.containersRight.set(rightHash, container); break; } // ... } } } // ... } } ``` When writing this post after the implementation, I now realize that I'm only checking for the left part of the container (e.g. the `[` symbol), so it's entirely possible to break the simulator by providing the malformed input. I'll need to think on adding more checks for the user input. For now, though, I'm just focusing on the core part of the parsing, logic, and rendering. There's nothing really special about rendering, apart from the fact, that the image is twice as wide, and apart from that change, it looks really similar to drawing boxes and walls: ```typescript const container = new Image(TILE_SIZE * 2, TILE_SIZE); container.src = "/Container.png"; export function drawContainer( ctx: CanvasRenderingContext2D, x: number, y: number, ) { ctx.drawImage(container, x, y, TILE_SIZE * 2, TILE_SIZE); } ``` ## Movement logic Updates to the bulldozer logic are pretty simple: we just take a look at which container might be affected (by looking up either left or right part), and try to move affected container. ```typescript export class Warehouse { // ... moveBulldozer(direction: Direction) { // ... if (this.boxes.has(nextPositionHash)) { // ... } else if (this.containersRight.has(nextPositionHash)) { const container = this.containersRight.get(nextPositionHash)!; if (this.canMoveContainer(container, direction)) { this.moveContainer(container, direction); this.bulldozer.position = nextPosition; } } else if (this.containersLeft.has(nextPositionHash)) { const container = this.containersLeft.get(nextPositionHash)!; if (this.canMoveContainer(container, direction)) { this.moveContainer(container, direction); this.bulldozer.position = nextPosition; } } // ... } } ``` When considering movement of the containers to the left or right, we need to only account two cases: when there's neighboring container or neighboring wall. When considering up or down movement of the containers, we need to account for multiple cases: - There is one container neighboring - There are two different containers neighboring - One of the next positions or both are walls Here are all of the possible configurations of the containers that we need to account for. For moving down, though, cases are the same, just mirrored horizontally. All of those configurations might look like a lot, but we can greatly simplify our code to handle them all. I'm not handling up and down separately, but together. Here's how it comes together in two methods: `canMoveContainer` and `moveContainer`. ```typescript export class Warehouse { // ... private canMoveContainer( container: Container, direction: Direction, ): boolean { const leftHash = getPositionHash(container.left); const rightHash = getPositionHash(container.right); const nextLeft = getNextPosition(container.left, direction); const nextRight = getNextPosition(container.right, direction); const nextLeftHash = getPositionHash(nextLeft); const nextRightHash = getPositionHash(nextRight); if (leftHash === nextRightHash) { if (this.containersRight.has(nextLeftHash)) { return this.canMoveContainer( this.containersRight.get(nextLeftHash)!, direction, ); } return !this.walls.has(nextLeftHash); } else if (rightHash === nextLeftHash) { if (this.containersLeft.has(nextRightHash)) { return this.canMoveContainer( this.containersLeft.get(nextRightHash)!, direction, ); } return !this.walls.has(nextRightHash); } else if (this.containersLeft.has(nextLeftHash)) { return this.canMoveContainer( this.containersLeft.get(nextLeftHash)!, direction, ); } else { let canMoveRight = false; if (this.containersLeft.has(nextRightHash)) { canMoveRight = this.canMoveContainer( this.containersLeft.get(nextRightHash)!, direction, ); } else { canMoveRight = !this.walls.has(nextRightHash); } let canMoveLeft = false; if (this.containersRight.has(nextLeftHash)) { canMoveLeft = this.canMoveContainer( this.containersRight.get(nextLeftHash)!, direction, ); } else { canMoveLeft = !this.walls.has(nextLeftHash); } return canMoveRight && canMoveLeft; } } private moveContainer(container: Container, direction: Direction) { const leftHash = getPositionHash(container.left); const rightHash = getPositionHash(container.right); const nextLeft = getNextPosition(container.left, direction); const nextRight = getNextPosition(container.right, direction); const nextLeftHash = getPositionHash(nextLeft); const nextRightHash = getPositionHash(nextRight); if (leftHash === nextRightHash) { if (this.containersRight.has(nextLeftHash)) { this.moveContainer(this.containersRight.get(nextLeftHash)!, direction); } } else if (rightHash === nextLeftHash) { if (this.containersLeft.has(nextRightHash)) { this.moveContainer(this.containersLeft.get(nextRightHash)!, direction); } } else if (this.containersLeft.has(nextLeftHash)) { this.moveContainer(this.containersLeft.get(nextLeftHash)!, direction); } else { if (this.containersLeft.has(nextRightHash)) { this.moveContainer(this.containersLeft.get(nextRightHash)!, direction); } if (this.containersRight.has(nextLeftHash)) { this.moveContainer(this.containersRight.get(nextLeftHash)!, direction); } } this.containersLeft.delete(leftHash); this.containersRight.delete(rightHash); this.containersLeft.set(nextLeftHash, { left: nextLeft, right: nextRight }); this.containersRight.set(nextRightHash, { left: nextLeft, right: nextRight, }); } } ``` These to functions, are again, recursive, just like the `canMoveBox` and `moveBox` are. And while reading the code, you can see why I've chosen Map for storing container left and right parts: it's so much easier to get the whole container this way! In December, I was solving these puzzles in Rust, like I mentioned in [this](/blog/the-hardest-day-of-aoc-2024-for-me) blog post. You can take a look at my original solution for day 15 [here](http://github.com/chornonoh-vova/advent-of-code-2024/blob/main/day-15/src/main.rs). But beware, this code is _terrible_ 😅 At this point, movement is working as expected, so I decided to tackle the next thing: deployment. ## Deploying I decided to use GitHub pages for this project, because I didn't had too much experience working with it. Here's how to create a GitHub action to deploy project to GitHub pages: ```yaml name: "Deploy to Pages" on: push: branches: ["main"] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: true jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Node uses: actions/setup-node@v4 with: node-version: lts/* cache: "npm" - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: # Upload dist folder path: "./dist" - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ``` This action is: - Running on every [push](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onpushbranchestagsbranches-ignoretags-ignore) to `main` branch or manually via [`workflow_dispatch`](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onworkflow_dispatch) - Ensures that only one job or workflow is running via [`concurrency`](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency) - Sets up [node](https://github.com/actions/setup-node), installs dependencies, and builds an artifact for deployment - And, finally, uploads and deploys artifact utilizing multiple actions: [actions/configure-pages](https://github.com/actions/configure-pages), [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact), [actions/deploy-pages](https://github.com/actions/deploy-pages) After pushing this workflow, and fixing vite configuration to include base like this: ```typescript export default defineConfig({ base: "/warehouse-simulator/", }); ``` I can finally share with you the link that you can visit and play for yourself: https://chornonoh-vova.github.io/warehouse-simulator/ 🥳 ## Bonus This is an entirely optional part, that I've wanted to tackle: moving both containers and boxes. For that, I've modified our example map that we were working with so far to include a couple of boxes: ```typescript export const exampleMap = "####################\n" + "##....[]....[]..[]##\n" + "##........O...OO..##\n" + "##..[][]....[]..[]##\n" + "##....OO@.....[]..##\n" + "##[]##....[]O.....##\n" + "##[]....[]....[]..##\n" + "##..[]OO..[]..OO[]##\n" + "##........[]......##\n" + "####################\n"; ``` And updated `canMoveBox`, `moveBox`, `canMoveContainer` and `moveContainer` functions in this [commit](https://github.com/chornonoh-vova/warehouse-simulator/commit/74b950adc08542729edd5ffabe9e993d85007616). Turns out, it was not really hard, I just had to carefully consider where I needed to add additional cases for movements. For example, when moving box, I've added checking of the affected containers by left or right part. And when moving container, I've added additional checks for boxes movements where previously I was only considering walls. ## Conclusion We've come a long way in this mini-series. From a blank slate to a fully working simulator, all without using any external dependencies! I know this simulator isn’t some great engineering marvel, to be honest, it’s a bit silly. But not every project needs to change the world. What matters most to me are the little mistakes, the bugs I’ve overcome along the way. I’ve always struggled to finish my side projects. But building these small, silly simulators and games not only brings me joy, it also helps me stay focused on a small, manageable scope. I hope these words inspire you to build something small and silly too. Because you can gain a lot of valuable experience along the way. As always, you can check out the full source code in this repository: [GitHub - Warehouse Simulator](https://github.com/chornonoh-vova/warehouse-simulator) I’ve also included some example inputs in the examples directory. And now, you can play the simulator here: [Play the Warehouse Simulator](https://chornonoh-vova.github.io/warehouse-simulator/) --- # Warehouse simulator from scratch: Part 1 URL: https://chornonoh-vova.com/blog/warehouse-simulator-part-1/ Date: 2025-06-21 > This post is part 1 of the miniseries. Read part 2 [here](/blog/warehouse-simulator-part-2). This blog post is inspired by the Advent of Code 2024 Day 15 puzzle: [Warehouse Woes](https://adventofcode.com/2024/day/15). I decided to build a simulation for it, and at the same time, challenge myself to not use any frameworks. Only HTML, CSS, JS, and drawing on a canvas! Well, almost. I decided to use TypeScript instead of JavaScript because it’s a bit easier to follow code with types, refactor code more confidently, and rely on the TS compiler to catch my mistakes. But other than that, my philosophy was simple: only hardcore, no frameworks 😉 Surprisingly, it was such a breath of fresh air, compared to working with React and doing the same boring enterprise development every day. So, here’s the walkthrough of how I did it. One note before we start, though, I will be omitting non-important bits of the code, but you can always take a look at the full source code in the [repository](https://github.com/chornonoh-vova/warehouse-simulator). ## Setup Let’s start with the basics: in the `index.html` file, I have a simple layout: ```html Warehouse Simulator

    Warehouse Simulator

    Welcome to the warehouse simulator based on Advent Of Code 2024 puzzle!

    Day 15: Warehouse Woes

    You can control a bulldozer via keyboard (on desktop) or with buttons (mobile) and move boxes and/or containers around

    Controls (desktop):

    • Up: press , w or k button
    • Left: press , a or h button
    • Down: press , s or j button
    • Right: press , d or l button

    You can also Upload txt file with input from puzzle and play around with that!

    At any point, click Reset to restore original map that you've uploaded

    ``` The main thing in it is our canvas, on which I’ll draw all of the simulation. You can notice that it has no width and height specified, but I decided to make the canvas responsive, so the script will be responsible for setting this up. I've also decided to try out native [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog) HTML element. In my day-to-day work I'm always utilizing some library to get this functionality for me, and I always thought that modals in HTML were hard to implement. Turns out, I was very wrong! Native HTML dialog is so easy to use, it's widely supported by browsers (its part of the baseline now), and the fact that it doesn't add up to you JS bundle is just a chef's kiss 🧑‍🍳. Later in the post I'll show a usage of it. I also used a lesser-known HTML element for the help content: [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/kbd), which semantically represents keyboard input and enhances accessibility. Here’s a breakdown of the styles for the page: ```css :root { --background-color-light: oklch(97% 0 0); --background-color-dark: oklch(14.5% 0 0); --background-color: var(--background-color-light); --border-color-light: oklch(20.5% 0 0); --border-color-dark: oklch(87% 0 0); --border-color: var(--border-color-light); --text-color-light: black; --text-color-dark: white; --text-color: var(--text-color-light); } @media (prefers-color-scheme: dark) { :root { --border-color: var(--border-color-dark); --background-color: var(--background-color-dark); --text-color: var(--text-color-dark); } } @font-face { font-family: "Pixelify Sans"; src: url("PixelifySans-Regular.woff2") format("woff2"); } body { width: 100vw; height: 100vh; background-color: var(--background-color); font-family: "Pixelify Sans", system-ui; color: var(--text-color); } /* ... */ #warehouse { width: 100%; height: 60vh; image-rendering: pixelated; } #mobile-controls { display: none; } /* ... */ .button { --shadow-color: color-mix(in oklch, var(--border-color) 30%, transparent); color: var(--text-color-dark); background-color: #009688; padding: 16px 32px; font-size: 1.5rem; box-shadow: 0px 5px var(--border-color), 0px -5px var(--border-color), 5px 0px var(--border-color), -5px 0px var(--border-color), 0px 10px var(--shadow-color), 5px 5px var(--shadow-color), -5px 5px var(--shadow-color), inset 0px 5px #ffffff36; } .button:active { transform: translateY(5px); box-shadow: 0px 5px var(--border-color), 0px -5px var(--border-color), 5px 0px var(--border-color), -5px 0px var(--border-color), inset 0px 5px var(--shadow-color); } /* ... */ @media (width < 768px) { /* ... */ #mobile-controls { display: flex; } /* ... */ } ``` The main thing to note here is the sizing of the canvas. The width is set to 100%, and the height is set to 60vh (CSS unit to specify vertical height). Other interesting things are: - hiding the mobile controls on the desktop (with `display: none`) because I wanted to make desktop controls entirely from the keyboard - CSS variables usage for colors to have a nice adaptation to the preferred color scheme of the user’s device - using [`box-shadow`](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow) to achieve pixelated borders effect for buttons and the canvas itself - using [`color-mix()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix) function to add transparency to already existing color Let’s now take a look at the renderer setup: ```typescript const canvas = document.getElementById("warehouse")! as HTMLCanvasElement; const ctx = canvas.getContext("2d")!; function resize() { const { width, height } = canvas.getBoundingClientRect(); const scale = window.devicePixelRatio; canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(scale, scale); } function render() { // ... requestAnimationFrame(render); } // ... window.addEventListener("resize", resize); requestAnimationFrame(() => { resize(); render(); }); ``` In the `resize` function, I’m setting up the _width_ and _height_ of the canvas depending on the measured client rect of the element. I followed the best practices on MDN about [device pixel ratio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio). I’m setting up the render loop by calling the `render` function first, and then deferring the next call via `requestAnimationFrame`. Rendering of the entities will be covered later. Here's the whole setup to use ``, that we discussed before: ```typescript const helpDialog = document.getElementById("help-modal")! as HTMLDialogElement; document.getElementById("help-btn")?.addEventListener("click", () => { helpDialog.showModal(); }); document.querySelectorAll(".close").forEach((btn) => { btn.addEventListener("click", () => { (btn.parentElement as HTMLDialogElement).close(); }); }); ``` ## Puzzle: part 1 Now, let's go through the puzzle itself and how I'm displaying the entities on the canvas. Map of the warehouse looks like this: ```text ########## #..O..O.O# #......O.# #.OO..O.O# #..O@..O.# #O#..O...# #O..O..O.# #.OO.O.OO# #....O...# ########## ``` Where `#` symbol denotes a wall, `O` symbol denotes a box, that can be moved, and `@` symbol denotes a starting position of the robot. I will be using bulldozer instead, because I liked pixel art that I've drawn for it quite a bit more. Original puzzle also includes a sequence of moves, but I'll be ignoring that, because I want to allow the user to freely move around the warehouse and move boxes. The main rule is that bulldozer can move one or more boxes as long as it's not limited by a wall. I will be encoding this rule later, but now let's focus on parsing the warehouse map and drawing our entities. ```typescript export class Warehouse { width: number; height: number; walls: Set; boxes: Set; bulldozer: { position: Position; direction: Direction; }; constructor(map: string) { const tiles = map .trim() .split("\n") .filter((l) => l.startsWith("#") && l.endsWith("#")) .map((l) => l.split("")); this.height = tiles.length; this.width = tiles[0].length; this.walls = new Set(); this.boxes = new Set(); let bulldozerPosition = { row: 0, col: 0 }; for (let row = 0; row < this.height; ++row) { for (let col = 0; col < this.width; ++col) { const tile = tiles[row][col]; const position = { row, col }; const positionHash = getPositionHash(position); switch (tile) { case "#": { this.walls.add(positionHash); break; } case "O": { this.boxes.add(positionHash); break; } case "@": { bulldozerPosition = position; break; } } } } this.bulldozer = { position: bulldozerPosition, direction: DIRECTIONS[3], }; } } ``` This class contains information about the warehouse, and I've setup parsing of the map in the constructor. I chose to use JS Set to store the positions of walls and boxes, but I encountered a limitation here. My position is represented by an object, and when storing objects event with the same properties, they are treated by JS as different (because they are compared by reference instead of value). That's why I store position hashes in the Set instead. Let's take a look at the position: ```typescript export type Position = { row: number; col: number; }; export type Direction = readonly [number, number]; export type PositionHash = `${number}:${number}`; export function getPositionHash({ row, col }: Position): PositionHash { return `${row}:${col}`; } export function getNextPosition( { row, col }: Position, [dr, dc]: Direction, ): Position { return { row: row + dr, col: col + dc }; } ``` Hash of the position is pretty simple: it's just a string that contains two numbers (row and column), delimited by `:`. But that structure allows us positions in a Set and maintain uniqueness. There's also a reference to directions, and they are specified just like in my other [blog post](/blog/flood-fill-algorithm#boundaries): ```typescript const WASD = ["w", "d", "s", "a"]; const HJKL = ["k", "l", "j", "h"]; const ARROWS = ["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]; export const ALL_KEYS = [...WASD, ...HJKL, ...ARROWS]; export const DIRECTIONS = [ [-1, 0], // Up [0, 1], // Right [1, 0], // Down [0, -1], // Left ] as const; export const TILE_SIZE = 32; export const exampleMap = "##########\n" + "#..O..O.O#\n" + "#......O.#\n" + "#.OO..O.O#\n" + "#..O@..O.#\n" + "#O#..O...#\n" + "#O..O..O.#\n" + "#.OO.O.OO#\n" + "#....O...#\n" + "##########\n"; ``` These are the shared constants that define various aspects of the simulation, like keyboard keys that can control bulldozer, directions, pixel size of tile, and a starting map. With the warehouse state parsed and stored, we can now visualize it. Here's how rendering works using the canvas. ```typescript function render() { const scale = window.devicePixelRatio; const canvasWidth = Math.floor(canvas.width / scale); const canvasHeight = Math.floor(canvas.height / scale); const warehouseWidth = warehouse.width * TILE_SIZE; const warehouseHeight = warehouse.height * TILE_SIZE; const bulldozerX = warehouse.bulldozer.position.col * 2 * TILE_SIZE; const bulldozerY = warehouse.bulldozer.position.row * 2 * TILE_SIZE; let offsetX = (canvasWidth - warehouseWidth) / 2; let offsetY = (canvasHeight - warehouseHeight) / 2; if (offsetX < 0) { const offsetMinX = canvasWidth - warehouseWidth; offsetX = clamp((canvasWidth - bulldozerX) / 2, offsetMinX, 0); } if (offsetY < 0) { const offsetMinY = canvasHeight - warehouseHeight; offsetY = clamp((canvasHeight - bulldozerY) / 2, offsetMinY, 0); } ctx.clearRect(0, 0, canvasWidth, canvasHeight); for (let row = 0; row < warehouse.height; ++row) { for (let col = 0; col < warehouse.width; ++col) { const tilePositionHash = getPositionHash({ row, col }); const x = Math.floor(offsetX + col * TILE_SIZE); const y = Math.floor(offsetY + row * TILE_SIZE); if (warehouse.walls.has(tilePositionHash)) { drawWall(ctx, x, y); } else if (warehouse.boxes.has(tilePositionHash)) { drawBox(ctx, x, y); } } } drawBulldozer( ctx, offsetX + warehouse.bulldozer.position.col * TILE_SIZE, offsetY + warehouse.bulldozer.position.row * TILE_SIZE, warehouse.bulldozer.direction, ); requestAnimationFrame(render); } ``` The most important thing here is the calculation of the `offsetX` and `offsetY` values, they are needed to properly draw our entities. There are two distinct cases: when canvas width is greater than warehouse width and vice versa. In the first case, I'm just placing warehouse at the center of the canvas, but in the second case, I'm making sure that bulldozer is always in view. Here's an implementation of a simple `clamp` function, which is used in the renderer to limit the offset values between min and max values: ```typescript export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } ``` Let's now take a look at functions that draw individual entities: ```typescript const wall = new Image(TILE_SIZE, TILE_SIZE); wall.src = "/Wall.png"; export function drawWall(ctx: CanvasRenderingContext2D, x: number, y: number) { ctx.drawImage(wall, x, y, TILE_SIZE, TILE_SIZE); } const box = new Image(TILE_SIZE, TILE_SIZE); box.src = "/Box.png"; export function drawBox(ctx: CanvasRenderingContext2D, x: number, y: number) { ctx.drawImage(box, x, y, TILE_SIZE, TILE_SIZE); } const bulldozer = new Image(TILE_SIZE, TILE_SIZE); bulldozer.src = "/Bulldozer.png"; export function drawBulldozer( ctx: CanvasRenderingContext2D, x: number, y: number, [dr, dc]: Direction, ) { if (dc === 0) { ctx.save(); if (dr == -1) { ctx.translate(x + TILE_SIZE, y); ctx.rotate(Math.PI / 2); } else { ctx.translate(x, y + TILE_SIZE); ctx.rotate(-(Math.PI / 2)); } ctx.drawImage(bulldozer, 0, 0, TILE_SIZE, TILE_SIZE); ctx.restore(); } else { if (dc === 1) { ctx.save(); ctx.translate(x + TILE_SIZE, y); ctx.scale(-1, 1); ctx.drawImage(bulldozer, 0, 0, TILE_SIZE, TILE_SIZE); ctx.restore(); } else { ctx.drawImage(bulldozer, x, y, TILE_SIZE, TILE_SIZE); } } } await Promise.all( [wall, box, bulldozer].map((img) => { return new Promise((resolve) => { img.onload = resolve; }); }), ); ``` Images of the walls and boxes are drawn as-is, but bulldozer drawing is a little bit involved, because it needs to react to the direction in which bulldozer is driving. In the case of Up and Down direction, I'm rotating an image by +90° of -90° respectively. But it's also applying translation by X or Y so the image stays in the same place. In the case of Right direction, I'm mirroring image vertically, for which I also need to translate by X so the image stays in the same place. Left direction is default, so in this case, it's just a simple `drawImage` call. ## Adding interactivity Here's how to set up custom map upload: ```typescript let uploadedMap: string | undefined; // ... document.getElementById("upload")?.addEventListener("click", () => { mapInput.click(); }); mapInput.addEventListener("change", async () => { const file = mapInput.files?.[0]; if (!file) { return; } const map = await file.text(); uploadedMap = map; warehouse = new Warehouse(uploadedMap); }); // ... document.getElementById("reset")?.addEventListener("click", () => { warehouse = new Warehouse(uploadedMap ?? exampleMap); }); ``` Let's now take a look at implementing bulldozer movement. The first step is to add the logic to the `Warehouse` class: ```typescript class Warehouse { // ... moveBulldozer(direction: Direction) { const nextPosition = getNextPosition(this.bulldozer.position, direction); const nextPositionHash = getPositionHash(nextPosition); if (this.boxes.has(nextPositionHash)) { if (this.canMoveBox(nextPosition, direction)) { this.moveBox(nextPosition, direction); this.bulldozer.position = nextPosition; } } else if (!this.walls.has(nextPositionHash)) { this.bulldozer.position = nextPosition; } } private canMoveBox(boxPosition: Position, direction: Direction): boolean { const nextBoxPosition = getNextPosition(boxPosition, direction); const nextBoxPositionHash = getPositionHash(nextBoxPosition); if (this.boxes.has(nextBoxPositionHash)) { return this.canMoveBox(nextBoxPosition, direction); } else { return !this.walls.has(nextBoxPositionHash); } } private moveBox(boxPosition: Position, direction: Direction) { const nextBoxPosition = getNextPosition(boxPosition, direction); const nextBoxPositionHash = getPositionHash(nextBoxPosition); if (this.boxes.has(nextBoxPositionHash)) { this.moveBox(nextBoxPosition, direction); } this.boxes.delete(getPositionHash(boxPosition)); this.boxes.add(nextBoxPositionHash); } } ``` The logic is as follows: - when trying to move the bulldozer, we first check is the next position is a box - if it is a box, we look whether we can move the box - `canMoveBox` recursively checks whether then next box can be moved, and returns false if next position hits a wall - finally, after we found out that box can be moved, we move the box and bulldozer after it - if it is not a box, we check if it's not a wall, and we move a bulldozer only in this case Here's how keyboard and button controls are set up: ```typescript document.addEventListener( "keydown", (event) => { const key = event.key; if (!ALL_KEYS.includes(key)) { return; } const direction = DIRECTIONS[ALL_KEYS.indexOf(key) % 4]; warehouse.bulldozer.direction = direction; warehouse.moveBulldozer(direction); }, { passive: true }, ); const mapInput = document.getElementById("map")! as HTMLInputElement; [ document.getElementById("up"), document.getElementById("right"), document.getElementById("down"), document.getElementById("left"), ].forEach((btn, idx) => { btn?.addEventListener("click", (event) => { event.preventDefault(); const direction = DIRECTIONS[idx]; warehouse.bulldozer.direction = direction; warehouse.moveBulldozer(direction); }); }); ``` I placed `ALL_KEYS` in an array in such a way, that key for moving up is placed first, right - second, down - third, left - fourth. So to get the direction to move (and `DIRECTIONS` array is structured in the same way) we can get the index and modulo it by 4. In the case of buttons, to not repeat the same `addEventListener`, I've added all of the buttons in the array, in the same order as `DIRECTIONS` and index of a button is just an index of the direction. ## Wrapping up Part 1 That's pretty much it about this simulation. Here's how it looks like: ![Warehouse simulator](../../assets/images/warehouse-map-to-canvas.png) I've decided to split the simulation into 2 parts (just like the original puzzle is split into 2 parts). So stay tuned for a part 2, where I'll implement the rest of the moving logic, new entity type - container, and its rendering. And, I'll publish a playable version - so you can play it from your browser! --- # TypeScript vs JavaScript private URL: https://chornonoh-vova.com/blog/typescript-vs-javascript-private/ Date: 2025-06-14 The first versions of JS lacked many features. JavaScript started without classes, visibility modifiers, or traditional OOP features. Instead, it relied on a unique, prototype-based inheritance model. Clunky? Definitely. But once you understood it, it just worked. Even when the classes were introduced natively in ES6, they were basically a wrapper around an old way of creating classes. Let’s take a glimpse into the past and look at how classes were implemented back then. ```javascript function Person(name, age) { this.name = name; this.age = age; } ``` This constructor function defines a Person class with two properties: name and age. We can define some methods for the class like so: ```javascript Person.prototype.greet = function () { console.log("Hello, my name is " + this.name); }; ``` Here’s how we can extend a class: ```javascript function Student(name, age, grade) { Person.call(this, name, age); // Call parent constructor this.grade = grade; } Student.prototype = Object.create(Person.prototype); // Inherit from Person Student.prototype.study = function () { console.log("Studying hard!"); }; ``` Usage: ```javascript var person1 = new Person("Alice", 30); person1.greet(); var student1 = new Student("Bob", 18, "A"); student1.greet(); // Inherited from Person student1.study(); ``` Classes were introduced in the ES6. At their core, they are just syntactic sugar on top of what we’ve just seen. They were designed to streamline OOP programming in JS. Let’s rewrite our examples from before with ES6: ```javascript class Person { constructor(name, age) { this.name = name; this.age = age; } greet() { console.log("Hello, my name is " + this.name); } } class Student extends Person { constructor(name, age, grade) { super(name, age); this.grade = grade; } study() { console.log("Studying hard!"); } } ``` Usage remains unchanged (with the only exception that var is now replaced with `const`): ```javascript const person1 = new Person("Alice", 30); person1.greet(); const student1 = new Student("Bob", 18, "A"); student1.greet(); student1.study(); ``` In my opinion, a modern way of working with classes in JS is much better and more streamlined. There is one problem, though, that I want to point out: there are no visibility modifiers! TypeScript visibility modifiers were designed to solve this issue. Let’s take a look at an updated example from before, now annotated with TS visibility modifiers: ```typescript class Person { private name: string; private age: number; constructor(name: string, age: number) { this.name = name; this.age = age; } greet() { console.log("Hello, my name is " + this.name); } } class Student extends Person { private grade: string; constructor(name: string, age: number, grade: string) { super(name, age); this.grade = grade; } study() { console.log("Studying hard!"); } } const person1 = new Person("Alice", 30); person1.greet(); const student1 = new Student("Bob", 18, "A"); student1.greet(); student1.study(); ``` I’ve chosen to make properties of a Person class private as well, which leads to them not being available in the Student subclass (we’ll take a look at that problem after). If we try to access private property outside a class, we get an error, just as expected: ```typescript person1.name; // ^ Property 'name' is private and only accessible within class 'Person'. student1.grade; // ^ Property 'grade' is private and only accessible within class 'Student'. ``` But there’s a caveat: this check is compile-time only, and that means if we take a look at the transpiled JS code: ```javascript "use strict"; class Person { constructor(name, age) { this.name = name; this.age = age; } // Omitting methods } class Student extends Person { constructor(name, age, grade) { super(name, age); this.grade = grade; } // Omitting methods } // Instantiations, etc. person1.name; // <- Accessing private property! student1.grade; // <- Accessing private property! ``` We can observe that we can easily access private members from the JS code. It is mentioned here in the TS [docs](https://www.typescriptlang.org/docs/handbook/2/classes.html#caveats). What we can do about it? There private properties syntax available in the recent JS versions: [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties), it also has great support across all browsers. Unlike TypeScript’s `private`, which only exists at compile-time, JavaScript's `#private` fields enforce privacy at runtime, making them more robust against accidental or malicious access. Let’s rewrite our example from before with JS private properties: ```typescript class Person { #name: string; #age: number; constructor(name: string, age: number) { this.#name = name; this.#age = age; } greet() { console.log("Hello, my name is " + this.#name); } } class Student extends Person { #grade: string; constructor(name: string, age: number, grade: string) { super(name, age); this.#grade = grade; } study() { console.log("Studying hard!"); } } // Consumer code remains unchanged ``` Now, when we try to access `person1.#name` or `student1.#grade` - we get an error in runtime! It is compiled to JS without stripping down private modifiers: ```javascript "use strict"; class Person { #name; #age; constructor(name, age) { this.#name = name; this.#age = age; } greet() { console.log("Hello, my name is " + this.#name); } } class Student extends Person { #grade; constructor(name, age, grade) { super(name, age); this.#grade = grade; } study() { console.log("Studying hard!"); } } // Consumer code remains unchanged ``` There are a couple of advantages when using JS private modifiers: - Truly private in runtime - TS still checks for access in compile-time Don’t worry, if you are compiling your code for older targets, let’s check out how TS achieves this in targets ES2021 and lower: ```javascript "use strict"; var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { if (kind === "m") throw new TypeError("Private method is not writable"); if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); if ( typeof state === "function" ? receiver !== state || !f : !state.has(receiver) ) throw new TypeError( "Cannot write private member to an object whose class did not declare it", ); return ( kind === "a" ? f.call(receiver, value) : f ? (f.value = value) : state.set(receiver, value), value ); }; var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if ( typeof state === "function" ? receiver !== state || !f : !state.has(receiver) ) throw new TypeError( "Cannot read private member from an object whose class did not declare it", ); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var _Person_name, _Person_age, _Student_grade; class Person { constructor(name, age) { _Person_name.set(this, void 0); _Person_age.set(this, void 0); __classPrivateFieldSet(this, _Person_name, name, "f"); __classPrivateFieldSet(this, _Person_age, age, "f"); } greet() { console.log( "Hello, my name is " + __classPrivateFieldGet(this, _Person_name, "f"), ); } } ((_Person_name = new WeakMap()), (_Person_age = new WeakMap())); class Student extends Person { constructor(name, age, grade) { super(name, age); _Student_grade.set(this, void 0); __classPrivateFieldSet(this, _Student_grade, grade, "f"); } study() { console.log("Studying hard!"); } } _Student_grade = new WeakMap(); ``` We are still achieving private access in runtime here, but this leads to an unfortunate drawback: a little performance penalty because TS has to backport this feature with `WeakMap` (which is a cool under-used feature of JS, btw). But I’d argue that a performance penalty is negligible in this case. It is still important to understand that, though. This transpilation can increase debugging complexity. So there are definitely some trade-offs. Let’s now go back to the elephant in the room, that I’ve set up a little earlier: how do we access some properties/methods in the child classes while maintaining privacy for the outer world? TS has an answer for that: protected visibility modifier. Unfortunately, JavaScript doesn't have a protected keyword, which means there's no built-in way to allow access to a field only from within the class and its subclasses. You're left choosing between: - `#private` (strict privacy, no subclass access), - or `protected` in TS (compile-time only, not truly private in JS). Let’s take a brief look at the TS example of the protected modifier: ```typescript class Person { protected name: string; protected age: number; constructor(name: string, age: number) { this.name = name; this.age = age; } // Omitting methods } class Student extends Person { private grade: string; constructor(name: string, age: number, grade: string) { super(name, age); this.grade = grade; } fullInfo() { // Now, protected fields from Person are available in Student console.log("name", this.name, "age", this.age, "grade", this.grade); } // Omitting other methods } ``` ## Final Thoughts - Use TypeScript's `private` or `protected` when you're working in a TS environment and want stronger compile-time safety. - Use JavaScript’s `#private` fields if runtime enforcement is important, or you're shipping a library that should protect the internals. The good news? You can combine both — using `#private` fields in TS gives you runtime safety plus TypeScript’s type-checking, offering the best of both worlds. --- # Flood fill algorithm URL: https://chornonoh-vova.com/blog/flood-fill-algorithm/ Date: 2025-06-07 Flood fill is the algorithm behind the "paint bucket" tool in MS Paint, Inkscape, and many other image editing software. The principle behind it, surprisingly, can be applied in a game such as Minesweeper (like revealing empty adjacent squares after clicking a blank tile). It can also be used in image processing to identify regions of the image with the same features. ## How it works Before starting a discussion on how the algorithm works, let's define our input: 2-D array. Each number in this array represents the pixel of an image. For simplicity, I'll only pick 5 colors: 0 (transparent), 1 (red ), 2 (orange ), 3 (lime ), 4 (blue ), 5 (fuchsia ). In reality, though, images are actually represented by 3-D matrix, because every pixel consists of three components: red, green and blue. Additionally, every pixel can contain alpha value (transparency), for example in PNG images. Each pixel has an X and Y coordinate, where Y is the row and X is the column of the 2-D array. The X-axis goes from left to right, and the Y-axis goes from top to bottom. For example, pixel `(0, 0)` is placed in the top left corner, and pixel `(3, 5)` is placed in the bottom right corner. Here is how the simplest image will look like in our example: This image has 4 rows and 6 columns, and in memory it will be represented by the following array: ```json [ [1, 0, 0, 0, 0, 0], [0, 1, 0, 0, 2, 2], [3, 0, 0, 0, 0, 0], [3, 3, 0, 0, 0, 4] ] ``` ### Starting point Algorithm begins with a coordinate of a starting pixel. It is chosen by the user of a program, for example clicking on a pixel in Paint or clicking on a square in Minesweeper. Let's select pixel `(2, 3)` as our starting point: ### Expansion Then, we need to recursively or iteratively visit all of the neighboring pixels. This is where we can choose what pixels we are considering neighboring. For example, considering only pixels in the same row or column, or also diagonal pixels. Here's all of the neighbors of `(2, 3)` highlighted when performing 4-way expansion: Here's all of the neighbors of `(2, 3)` highlighted when performing 8-way expansion: ### Boundaries Expansion stops when we reach a pixel that has a different color or coordinates go out of bounds of the image. Here's an example of resulting image with 4-way expansion: There are a couple of choices we can make when implementing this algorithm. We already discussed one of them (when talking about neighbors). The second choice we can make is _how_ we'll iterate over all of the pixels, that satisfy a given criteria: with DFS (depth-first search) or BFS (breadth-first search). But before we start, let's define a type and constant that will be used in both implementations: ```typescript type Pixel = { row: number; col: number; }; const DIRECTIONS = [ [-1, 0], // up [0, 1], // right [1, 0], // down [0, -1], // left ]; ``` ## DFS Approach The defining characteristic of the DFS is that before visiting a next neighbor, we first visit all of the children of the current node. So, from the previous example, we will visit pixels in the following order: ``` 1: [ 2, 3 ] 2: [ 1, 3 ] 3: [ 0, 3 ] 4: [ 0, 4 ] 5: [ 0, 5 ] 6: [ 0, 2 ] 7: [ 1, 2 ] 8: [ 2, 2 ] 9: [ 3, 2 ] 10: [ 3, 3 ] 11: [ 3, 4 ] 12: [ 2, 4 ] 13: [ 2, 5 ] 14: [ 2, 1 ] 15: [ 0, 1 ] ``` Let’s number all of the painted pixels in the order that they are visited from the example before: Here's an implementation of this approach: ```typescript function floodFillDFS( image: number[][], startRow: number, startCol: number, color: number, ): number[][] { const n = image.length; const m = image[0].length; if (image[startRow][startCol] === color) { return image; } const startColor = image[startRow][startCol]; function dfs({ row, col }: Pixel) { if ( row < 0 || col < 0 || row >= n || col >= m || image[row][col] !== startColor ) { return; } image[row][col] = color; for (const [dr, dc] of DIRECTIONS) { const [nextRow, nextCol] = [row + dr, col + dc]; dfs({ row: nextRow, col: nextCol }); } } dfs({ row: startRow, col: startCol }); return image; } ``` DFS approach relies on recursion to visit all of the neighbors of the current pixel. There is also a nice little trick that I've utilized here: the `DIRECTIONS` array. Consider the code without it, it would have looked something like this: ```typescript dfs({ row: row - 1, col }); // up dfs({ row, col: col + 1 }); // right dfs({ row: row + 1, col }); // down dfs({ row, col: col - 1 }); // left ``` This code is essentially doing the same, it visits all of the neighbors, but in my opinion, approach with `DIRECTIONS` array is better, because it looks cleaner, and when we need to change what neighbors we consider, we can just update this array, without rewriting the code. Here is an interactive playground with the 10x10 image: In this playground, you can select a fill color, click on a pixel, and observe in real-time how DFS performs a flood fill! You can also reset the image back to its initial state by pressing the "Reset" button. ## BFS approach BFS, on the other hand, visits all of the neighbors before moving to a deeper level. Again, taking our example, we will visit pixels in the following order: ``` 1: [ 2, 3 ] 2: [ 1, 3 ] 3: [ 2, 4 ] 4: [ 3, 3 ] 5: [ 2, 2 ] 6: [ 0, 3 ] 7: [ 1, 2 ] 8: [ 2, 5 ] 9: [ 3, 4 ] 10: [ 3, 2 ] 11: [ 2, 1 ] 12: [ 0, 4 ] 13: [ 0, 2 ] 14: [ 0, 5 ] 15: [ 0, 1 ] ``` Let's number all of the painted pixels in the order that they are visited once again: Here's an implementation of this approach: ```typescript function floodFillBFS( image: number[][], startRow: number, startCol: number, color: number, ): number[][] { const n = image.length; const m = image[0].length; if (image[startRow][startCol] === color) { return image; } const startColor = image[startRow][startCol]; const queue = new Queue([{ row: startRow, col: startCol }]); while (!queue.isEmpty()) { const { row, col } = queue.pop(); if ( row < 0 || col < 0 || row >= n || col >= m || image[row][col] !== startColor ) { continue; } image[row][col] = color; for (const [dr, dc] of DIRECTIONS) { const [nextRow, nextCol] = [row + dr, col + dc]; queue.push({ row: nextRow, col: nextCol }); } } return image; } ``` I've used [datastructures-js/queue](https://github.com/datastructures-js/queue) for a Queue implementation. Note that `queue.pop()` is removing items from the front, instead removing them from back in plain JS array. In the BFS approach we visit all of the children iteratively, and that can be a plus on a very large images (imagine getting a stack overflow error in paint). But otherwise, two approaches are almost identical. Here's an interactive playground with 10x10 image: In this playground, you can select a fill color, click on a pixel, and observe in real-time how BFS performs a flood fill, and compare it with DFS that you saw previously. You can also reset the image back to its initial state by pressing the "Reset" button, just as in example from before. ## Conclusion Flood fill is a deceptively simple algorithm with a wide range of practical uses — from graphics editors to games and image analysis. By starting from a single pixel and expanding outwards, either recursively with DFS or iteratively with BFS, we can quickly identify and fill connected regions of similar color. While both DFS and BFS approaches achieve the same goal, they differ in performance characteristics. DFS is elegant and easy to implement with recursion, but may hit stack limits on large inputs. BFS is more memory-intensive but safer for deep fills. Understanding how flood fill works not only helps with building graphical tools or solving programming puzzles, but it also offers a great opportunity to explore fundamental graph traversal concepts in a visual and intuitive way. --- # One of my favorite Java interview questions URL: https://chornonoh-vova.com/blog/one-of-my-favorite-java-interview-questions/ Date: 2025-05-31 As I gain more and more experience conducting technical interviews, I realize it's really important to ask open-ended questions instead of close-ended ones. Open-ended questions allow for free-form answers, they encourage detailed responses and allow for more exploration of the opinions. Close-ended questions, on the other hand, are easily answered with a fixed set of answers, in the worst case "yes" or "no". | Close-ended | Open-ended | | --------------------------------------------------- | ----------------------------------------------------------- | | Simple, specific answer | More expansive answer, encourage discussion | | Close or limit the scope of conversation | Open up a conversation, facilitate exploration of the topic | | Usually have a set of predefined answers | Have no predefined answers | | Useful for collecting quantitative data and quizzes | Useful for collecting qualitative data and deeper insights | Now I understand, this is obvious. But as a beginner, I didn't fully grasp that. In my practice, I found this really simple (at first glance) question: "How to implement a Singleton in Java?" to be a good open-ended question. It allows us to discuss in great detail a wide range of Java topics - from fundamentals to the more advanced concurrency techniques. Let's explore six implementations of the Java Singleton pattern - from eager instantiation to the initialization-on-demand holder idiom - and discuss when you might (or might not) want to use it. ## How to implement a Singleton in Java? Firstly, this question tests a baseline knowledge on how to create a singleton, and (almost) all the implementations share the same pattern: - Private constructor (to prevent others from creating new instances except the class itself) - Static method to obtain an instance of this class There are also a couple of different approaches to the creation of a singleton instance itself ### Eager initialization ```java package com.example; public class EagerSingleton { private static final EagerSingleton instance = new EagerSingleton(); private EagerSingleton() {} public static EagerSingleton getInstance() { return instance; } // some other methods... } ``` This approach creates an instance of a singleton at the moment when the class is loaded by the JVM. It's very easy to implement, but it has a downside of always creating an instance, and it might not be applicable in every scenario. Let's try to solve this problem by initializing our singleton lazily. ### Lazy initialization ```java package com.example; public class UnsafeSingleton { private static UnsafeSingleton instance = null; private UnsafeSingleton() {} public static UnsafeSingleton getInstance() { if (instance == null) { instance = new UnsafeSingleton(); } return instance; } // some other methods... } ``` This approach, on the other hand, defers instantiation of a singleton instance to the moment when the `getInstance` method is first called. Even though this approach solves a problem of eager instantiation, it has one significant downside: it's not thread-safe. Let's try reproducing this problem by trying to spawn 2 threads and calling `getInstance` at the same time [source](https://refactoring.guru/design-patterns/singleton/java/example). ```java package com.example; public class Main { public static void main(String[] args) { System.out.println("Result:"); Thread thread1 = new Thread(new Thread1()); Thread thread2 = new Thread(new Thread2()); thread1.start(); thread2.start(); } static class Thread1 implements Runnable { @Override public void run() { UnsafeSingleton singleton = UnsafeSingleton.getInstance(); System.out.println(singleton.toString()); } } static class Thread2 implements Runnable { @Override public void run() { UnsafeSingleton singleton = UnsafeSingleton.getInstance(); System.out.println(singleton.toString()); } } } ``` And in the console, we see the following output: ``` > Task :com.example.Main.main() Result: com.example.UnsafeSingleton@3d82cc27 com.example.UnsafeSingleton@61214655 ``` By default, the `toString` method prints a hash code of the object along with its name. By looking at the result, we can see that the `getInstance` method returns 2 different objects, which violates the singleton property. This observation perfectly leads to the follow-up question. ## How to make the creation thread-safe? Java has a couple of tools available to ensure thread-safety, let's explore three of them in this section. ### Synchronized method ```java package com.example; public class ThreadSafeSingleton { private static ThreadSafeSingleton instance = null; private ThreadSafeSingleton() {} public static synchronized ThreadSafeSingleton getInstance() { if (instance == null) { instance = new ThreadSafeSingleton(); } return instance; } // some other methods... } ``` This approach differs from a previous one simply by adding a synchronized modifier to the `getInstance` method. This addition ensures that no two threads can call this method at the same time - only one of them can call, the others have to wait. It has a disadvantage though: now every call to `getInstance` has to be synchronized. What we really want is that only a creation of the instance is synchronized (only the first time when it's called, basically). But after the creation, all threads can obtain a reference for their needs independently. How can we achieve that? ### Double-check locking ```java package com.example; public class DoubleCheckingSingleton { private static volatile DoubleCheckingSingleton instance = null; private DoubleCheckingSingleton() {} public static DoubleCheckingSingleton getInstance() { DoubleCheckingSingleton result = instance; if (result == null) { synchronized(DoubleCheckingSingleton.class) { result = instance; if (result == null) { instance = result = new DoubleCheckingSingleton(); } } } return result; } // some other methods... } ``` This approach utilizes [Double-checked locking](https://en.wikipedia.org/wiki/Double-checked_locking). It has an advantage of only acquiring a lock when it's required, but, as a downside, code to implement it becomes much more complicated. How can we simplify thread safety without compromising performance and code complexity? ### Inner holder ```java package com.example; public class HolderSingleton { private static class Holder { private static final HolderSingleton instance = new HolderSingleton(); } private HolderSingleton() {} public static HolderSingleton getInstance() { return Holder.instance; } // some other methods... } ``` This is a [technique](https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom) that I recently learned myself. Turns out, that inner classes are lazily created, so this example also defers the creation of the instance, but what is most interesting here is that it’s also thread-safe! I really liked this approach and the simplicity of it. But what if I tell you, that all the examples above can be broken? ### Serialization and Reflection Java has a lot more powerful tools, for example serialization and reflection. What if I told you, that serialized version of the singleton can be restored from the file as another instance? What if I told you that any private constructor can be effectively turned public? All of that is possible with serialization and reflection. To guard against serialization problems, classes need to implement `Serializable` and override `readResolve` method. Reflection can break even the best Singleton implementations. While guarding against it fully is non-trivial, it’s worth understanding the limitations - and I’d love to hear if you’ve found some elegant solutions! All these thoughts lead me to discovering an ultimate version of the singleton: ```java public enum EnumSingleton { INSTANCE; // some example state private int value; public int getValue() { return value; } public void setValue(int value) { this.value = value; } } ``` How it works: - An enum itself acts as a singleton. By defining an enum with a single instance, you guarantee that only one object of that enum type will ever exist. - The JVM handles enum instantiation, ensuring thread safety and preventing multiple instantiations through serialization or reflection. Unfortunately, there is no way to make it lazily created (at least one that I know of). All of these versions of the simplest pattern, simpler and more effective implementations of it in Spring and other frameworks lead us to almost philosophical question, whether or not should we know it all. More and more candidates don't have any idea on how you can implement Singleton from the ground up, without any libraries or frameworks. ## Should you really care? Singleton as a pattern itself has significant downsides: - It introduces a global state into the application, which can become hard to maintain - Hidden dependency - it can become hard to track down which classes depend on it - Complicates testing - due to the global nature of the singleton, it can make mocking more challenging On the other hand, it is useful in some cases, like: configuration classes, logging utilities, metrics collectors. In my day-to-day work, I mostly work with Spring. And Spring allows for Inversion of Control. Basically, the class can define the dependencies without actually creating them. This work is done by the IoC container, and Spring allows for more granular control over the lifetime of the objects: - singleton - prototype - request - session - application - websocket [Source](https://www.baeldung.com/spring-bean-scopes) By default, though beans have a singleton scope. In Spring, a 'bean' is a managed object created and wired by the framework. It is possible to specify singleton behavior explicitly, like this: `@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)`. But most of the time, it is left as default (unless it really needs to be different). Utilizing Springs IoC container solves the biggest problems about singletons: - Now it’s easily testable and mockable because dependent classes are not calling a static method to obtain an instance, relying on the container to provide one - It’s easier to see what classes dependencies - There can be a global state still, but it becomes much easier to deal with one With all the upsides and convenience of working with Spring, it might become tempting to over-rely on the framework. But in my opinion, it is still important to understand how Java works under the hood, how Spring itself is creating those beans under the hood. Some of the techniques that we can learn from this question (such as double-locking) can be really useful outside of the context of the singletons. ## Conclusion In a simple, single-threaded context, a straightforward utility class like `EagerSingleton` is fine. In a multi-threaded context prefer the `HolderSingleton` or `DoubleCheckingSingleton`. As a rule of thumb, it is much better to avoid singletons altogether in the code base. It is preferable to rely on the DI framework. And about interviews: while it's okay to lean on frameworks in day-to-day work, understanding the fundamentals shows true engineering mindset - and that's what we're looking for. Even though we discussed a lot of the details about Singletons today, the general concept remains very simple, implementation of it requires very little knowledge of concepts that I'm sure you already know (like constructors and static methods). This question designed to spark conversation, show off how you can apply basic and advanced Java concepts to one of the simplest problems that you can think of. Simply put: **Do not be a frameworker, be an engineer!**. --- # The hardest day of AoC 2024 (for me) URL: https://chornonoh-vova.com/blog/the-hardest-day-of-aoc-2024-for-me/ Date: 2025-05-24 Last year, I participated in the [Advent of Code](https://adventofcode.com). This is a collection of Christmas-themed programming puzzles. They are published every day from December 1st to December 25th. There is also a leader board available, but I did not aim for the record-breaking times. For some of the days, just completing them was a challenge as well! I even decided to give myself an extra challenge: I've decided to solve those puzzles in [Rust](https://www.rust-lang.org/). I really liked this language during my time learning it for a work-related project, and I tried applying it to solving these puzzles. And honestly, I learned more Rust doing those puzzles than I had in any structured setting. Some of the days were easier, and some days were harder, but one day really stood out for me: it's day 23. ## Part 1 Just like every puzzle, this one begins with a description and an example input. The puzzle is all about a LAN party, and how the computers in this network are connected to each other. In the first part of the puzzle, we are asked to find all of the sets of three inter-connected computers. Then we need to filter them so that they contain at least one computer that starts with letter _t_. And the result of the first part is the number of such sets. Let's start with parsing the input data: ```rust fn get_computer_links(input: &str) -> Vec<(&str, &str)> { input .lines() .filter_map(|line| line.split_once('-')) .collect() } ``` This step is not particularly interesting. But it uses a couple of the rust features, that I've found useful during my AoC journey with rust: - [lines](https://doc.rust-lang.org/std/str/struct.Lines.html) iterator - very useful for the parsing - [split_once](https://doc.rust-lang.org/std/string/struct.String.html#method.split_once) method for strings - [filter_map](https://doc.rust-lang.org/std/iter/struct.FilterMap.html) iterator - this was so new to me (I used to doing `filter` and `map` in the JS/TS) - [tuples](https://doc.rust-lang.org/std/primitive.tuple.html) - very useful for very simple data, when you don't want to create a struct for it Here is how we can view the graph that computers will form: On desktop, you can hover over the vertices of the graph, and neighbors will be highlighted. On mobile, just click on the vertex that you want to highlight. Now, let's iterate over all of the just-parsed links, and keep all of the vertices in the **vs** hash set and all of the neighbor pairs in the **neighbors** hash set. Then, I'm iterating over all of the links once again, and basically trying to find the 3rd vertex that is connected to both of the vertices of the given edge. Here is how I implemented the first part: ```rust fn count_t_computers(input: &str) -> usize { let computer_links = get_computer_links(input); let mut neighbors: HashSet<(&str, &str)> = HashSet::new(); let mut vs: HashSet<&str> = HashSet::new(); for (a, b) in &computer_links { vs.insert(a); vs.insert(b); neighbors.insert((a, b)); neighbors.insert((b, a)); } let mut cnt = 0; for (a, b) in &computer_links { for v in &vs { if !a.starts_with("t") && !b.starts_with("t") && !v.starts_with("t") { continue; } if neighbors.contains(&(v, a)) && neighbors.contains(&(b, v)) { cnt += 1; } } } cnt / 3 } ``` > Bonus question: What is the time complexity of this function? This code actually finds all sets of three inter-connected computers 3 times, that's why in the end I have count divided by 3. Unfortunately, I couldn't figure out a better way of doing it, if you have some ideas, don't hesitate to leave a comment with your suggestion. I've also removed all of the nasty console logging that I did initially when debugging. Here's what the graph looks like on the example data: You can hover over the graph vertices to see the other two interconnected computers. You can also observe that one computer might be a part of multiple sets. ## Part 2 After solving the first part quickly, I thought that the second part will be a breeze as well. And then I read the description: now I need to figure out the largest set of inter-connected computers. Initially, I just thought about expanding my initial solution that found 3 inter-connected computers to 4, 5 and so on. But I quickly realized, that this approach won't work out. Solve day 23 with nested loops - learn from Reddit the clique problem is NP-hard [Source](https://www.reddit.com/r/adventofcode/s/NfpvknjShr) Honestly, this is how I felt like on that day 😅 Turns out, a set of interconnected vertices in a graph is called [clique](https://www.algorist.com/problems/Clique.html). The problem of finding a [maximum clique](https://en.wikipedia.org/wiki/Clique_problem) is [NP-hard](https://en.wikipedia.org/wiki/NP-hardness) so basically, as hard of a problem as it can get. Here are some of the examples of cliques: Kudos to AoC creator - I admire your creativity! I love how AoC gives you such a fun description of a problem, and gently invites you to find a solution for a problem, even the problem that you wouldn't ever hear about. This is particularly important for me, frontend dev, to participate in such events, and challenge myself. But, lyrics aside, let's implement the Bron-Kerbosh algorithm in Rust to solve this problem: ```rust fn bron_kerbosch<'a>( mut p: HashSet<&'a str>, r: HashSet<&'a str>, mut x: HashSet<&'a str>, n: &HashMap<&'a str, HashSet<&'a str>>, ) -> Vec> { if p.is_empty() && x.is_empty() { return vec![r]; } let mut res = Vec::new(); for v in p.clone() { let mut nr = r.clone(); nr.insert(v); let np = p.clone().intersection(&n[&v]).cloned().collect(); let nx = x.clone().intersection(&n[&v]).cloned().collect(); res.extend(bron_kerbosch(np, nr, nx, n)); p.remove(&v); x.insert(v); } res } ``` This algorithm operates on 3 sets: - **r** is the set of vertices in the current clique - **p** is the set of vertices that can be added to the current clique - **x** is the set of vertices that cannot be added to the current clique Then we are iterating over all vertices in **p**, and try to add it to the current clique. Then function is recursively called on each iteration with **np** and **nx** that are the intersections of the current vertex neighbors. Kudos to this amazing video explaining the algorithm as well: [video](https://youtu.be/1cwu123VZ4Q?si=0HVYJNnfSHs__MOp) Here is how it's invoked: ```rust fn lan_party_password(input: &str) -> String { let computer_links = get_computer_links(input); let mut neighbors: HashMap<&str, HashSet<&str>> = HashMap::new(); let mut vs: HashSet<&str> = HashSet::new(); for (a, b) in computer_links.iter() { vs.insert(a); vs.insert(b); neighbors.entry(a).or_default().insert(b); neighbors.entry(b).or_default().insert(a); } let mut computers: Vec = bron_kerbosch(vs, HashSet::new(), HashSet::new(), &neighbors) .iter() .max_by(|c1, c2| c1.len().cmp(&c2.len())) .map(|vs| vs.iter().map(|v| v.to_string()).collect()) .unwrap_or_default(); computers.sort(); computers.join(",") } ``` And that's how these cliques look like on the example data: Now, when you hover over the vertex in a graph, the maximum clique that contains this vertex will be highlighted. If the hovered over vertex was part of multiple same-size cliques, only one will be highlighted. ## Conclusion That's my story solving the hardest day of Advent of Code 2024. You can take a look at a full source code for this and all of the other days in this repository: https://github.com/chornonoh-vova/advent-of-code-2024 I quite enjoyed writing this article and re-building the same algorithms but in TypeScript and hooking them up with React. You can always take a look at the source code of this and other articles in this repository: https://github.com/chornonoh-vova/website. Leave a comment below if you're interested in a walk through of how I built it. Thank you for reading this article, I hope that it inspired you to also participate in the Advent of Code 😉. See you in the next post! --- # LRU Cache: visualization & implementation URL: https://chornonoh-vova.com/blog/lru-cache-visualization-implementation/ Date: 2025-05-17 I’ve recently completed an [Algorithms in Practice course](https://www.csosvita.com/courses/algorithms-in-practice). One of the most practical and interesting topics were caches. I got interested in that topic, so I’ve decided to build one of the data structures that we discussed - the LRU (least recently used) cache and write a blog post about it. I’ve also built a visualization so it will be easier for anyone to understand. ## Cache Let’s start from the beginning- what is cache? Turns out it is pretty simple - it’s some kind of storage (hardware or software) the whole purpose of which is to store data that can be used in the future. The whole idea of cache is that it’s faster than the main source of data. Some examples include: - L1 cache in CPUs is faster than RAM - reads from the Redis cache are faster than reads from the PostgreSQL database ## Hits, misses, and eviction Caches are not infinite, though, because faster memory is pricier, and we cannot fit the whole database in memory. Therefore, caches typically contain only the subset of all keys. When the key that is being requested is found in a cache, it’s called a “cache hit”. When the key is absent from a cache, it’s called a “cache miss”. Using a cache wisely is to find a balance between the number of hits and misses. Generally, the more hits - the better. Caches have some capacity, and when it’s exceeded, caches need to remove some entries that are no longer (hopefully) needed. The process of automatic removal is called “eviction policy”. That’s where LRU (Least Recently Used) comes in - it’s one of the simplest cache eviction policies. The main idea is to remove an entry that was used least recently. ## Visualization Let’s now look at how the LRU cache works under the hood. In this section, you’ll see interactive examples, that you can play around with. There are two sections in each example: controls and state. Controls allow you to interact with a cache as an interface (get and set operations). There is also a reset button that restores initial state of the cache. The state section shows a representation of data that is stored inside the cache. New entries are added at the top, and the least recent are at the bottom. Caches have a capacity of 6. ### Cache hit Try to enter 3 into the "Key to get" field below, and clicking "Get" button. Observe how entry with key = 3 moves to the top, as it becomes most recently used. Also note, that number of hits increases in the cache stats. Now, try entering other keys, that are available in the cache, and observing how the state of the cache changes with every request. **Bonus question** > What should be the order of requests so the cache entries would be in the order 1 -> 2 -> 3 -> 4 -> 5 ? ### Cache miss Now, try to enter the key that doesn't exist in the cache, for example, 6. You can observe cache miss being logged and number of misses increasing. Most importantly, though, cache state shouldn't have changed at all. ### Cache eviction Now, try adding a new entry into the cache, for example with key **6** and value **lemon 🍋**. And this item will be added no problem. But try to add one more item, for example, **7**: **cherries 🍒**. You can observe how the least recent entry (apple) gets evicted from the cache, to make space for the new items (because in this visualization maximum capacity is 6). ### Playground Here is a full interactive playground, with an additional buttons to remove entries from the cache. ## Implementation Let’s now take a look at how we can implement it in JS/TS. Credit to this [tweet](https://x.com/dillon_mulroy/status/1920510178002186449) that inspired me to write this article. I took this implementation as a base and added some additional methods for my visualizations. The main idea comes from the fact that the built-in Map object in JS preserves an insertion order of the keys: [Map - JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#:~:text=The%20Map%20object%20holds%20key%2Dvalue%20pairs%20and%20remembers%20the%20original%20insertion%20order%20of%20the%20keys.) And here is the V8 source code that I’ve found that implements an ordered hash map:[v8/src/objects/ordered-hash-table.h at main · v8/v8 · GitHub](https://github.com/v8/v8/blob/main/src/objects/ordered-hash-table.h) The get method is really simple: we need to take a look into the underlying map and move the requested key to be most recent when it was found. The set method needs to handle the capacity, to make sure that our underlying map doesn’t grow too much. I’ve also added the entries method, which just returns an array of key/value pairs. I use this method to show the internal state of a cache in all visualizations. Here is a full code for the LRUCache class: ```ts export class LRUCache { #cache: Map; #capacity: number; constructor(capacity: number, initial?: Iterable) { this.#cache = new Map(initial); this.#capacity = capacity; } #setMostRecent(key: K, value: V): void { this.#cache.delete(key); this.#cache.set(key, value); } entries(): [K, V][] { const entries: [K, V][] = new Array(this.#cache.size); let idx = entries.length - 1; for (const entry of this.#cache.entries()) { entries[idx] = entry; idx--; } return entries; } get(key: K): V | undefined { const value = this.#cache.get(key); if (value === undefined) { return undefined; } this.#setMostRecent(key, value); return value; } set(key: K, value: V): void { this.#setMostRecent(key, value); if (this.#cache.size > this.#capacity) { const oldest = this.#cache.keys().next().value!; this.#cache.delete(oldest); } } delete(key: K): void { this.#cache.delete(key); } clear(): void { this.#cache.clear(); } } ``` The main difference from the tweet that I linked above is that I’ve used JS private properties instead of TS private modifier, but why I did so, is a topic for some post in the future, so stay tuned 😉 As you can see, this idea is pretty simple to implement, and I find that the visuals for it are stunning. With this newfound knowledge, I encourage you to practice this technique on leetcode: [LRU Cache - LeetCode](https://leetcode.com/problems/lru-cache/). Something that I found about myself, is that I’m learning better in practice. Thank you for reading this article, I hope that it was interesting and insightful for you. You can leave comments/suggestions in the form below. See you in the next post! --- # Better approach to automated UI testing URL: https://chornonoh-vova.com/blog/better-approach-to-automated-ui-testing/ Date: 2025-05-10 > The Only Constant in Life Is Change. - Heraclitus Recently, I’ve stumbled upon a challenge in my day-to-day work as a frontend developer: it’s constantly changing UI. To be honest, that's not the first time when I encounter changing requirements, but what makes it hard this time is the need for automation. Unfortunately, when the underlying interface changes in both content and structure, I had to rewrite a lot of tests as well. This led to some nasty bugs, and missing flows. I couldn’t iterate on the reworks quite as confidently because I knew for a fact that the changes that I introduce will lead to failing tests. After that, I couldn’t separate genuinely broken tests that indicated some bugs or tests that needed rewriting. In my opinion, this defeats the whole purpose of the testing in the first place. Furthermore, the QA team can’t reliably write automated tests for the UI, because one day the elements can be in one place but the next day, in another place. So I set out to find a solution for this problem: how could I write my most important tests in a way that even later changes wouldn’t affect them? And I think I’ve found a possible solution. ## Example case Let’s write a simple CRUD app and then test it. The application will consist of a single screen, and let the users see all of the music tracks in a list view with pagination. There will also be some actions available: create a new track, edit existing ones, upload some music files to them, listen to the music in a mini player, and delete tracks. Here is how the main page looks like: Screenshot of main page of example 'Track Manager' application. To build this application, I’ve used [React](https://react.dev/) and [TailwindCSS](https://tailwindcss.com/) for the UI, [shadcn/ui](https://ui.shadcn.com/) components, and [TanStack Query](http://tanstack.com/query/latest) for data fetching. I had a lot of fun writing it! You can look at the source code for the entire application [here](https://github.com/chornonoh-vova/track-manager). There’s a lot of testing ground to cover in one blog post, so I want to focus on one particular user flow: creating a new track. I think that this flow covers a lot of blockers that I’ve encountered, and would have led to many test rewrites with the old approach. There’s the flow that I want to focus on in this blog post: 1. The user clicks the “Create a new track” button 2. The modal with a form to fill out the track metadata is opened 3. The user clicks “Save” without filling any fields 4. Fields, that are required (such as the name of the track) show an error Here is how modal looks like initially: Screenshot of create track modal Here is how modal looks like with errors: Screenshot of create track modal with errors on the fields ## Writing the tests The first test: get the “Create Track” button, click it, and check that the dialog with the title “Create a Track” is visible. ```ts it("opens a create track modal", async () => { renderPage(); await expect .element(page.getByRole("button", { name: "Create Track" })) .toBeInTheDocument(); await page.getByRole("button", { name: "Create Track" }).click(); await expect .element(page.getByRole("dialog", { name: "Create a Track" })) .toBeInTheDocument(); }); ``` This is a good approach to testing - we are ensuring the business logic by interacting with elements that users see. This test is using Playwright’s [getByRole](https://playwright.dev/docs/locators#locate-by-role) locator. This way, we are also ensuring the accessible roles and names for elements along the way, which is great for a11y testing. But there are a couple of problems with this approach: - What if the text for a button changes? - What if the text for the dialog title changes? - What if we need to automatically test an application in a different language? All of these changes (and they are quite common) will lead to failing tests on the slightest change, and these tests cannot be used in automation scenarios. Let’s move on to a second case. In this test case, we are also clicking the “Create Track” button, and then immediately “Save” button and verifying that required fields have errors. ```ts it("should show errors when required fields are not filled", async () => { renderPage(); await page.getByRole("button", { name: "Create Track" }).click(); await page.getByRole("button", { name: "Save" }).click(); await expect .element(page.getByText("Track title is required")) .toBeInTheDocument(); await expect .element(page.getByText("Track artist is required")) .toBeInTheDocument(); }); ``` Here I’ve used another Playwright locator: [getByText](https://playwright.dev/docs/locators#locate-by-text). I’d argue that this locator is even more brittle than previous ones that I’ve used. Obviously, this locator has the same problems, that we discussed before, so I decided to take a step back and research a little about what can be used instead. I stumbled upon this locator in the Playwright docs: [getByTestId](https://playwright.dev/docs/locators#locate-by-test-id). The [Testing Library](https://testing-library.com/) has a similar solution exactly for that: [getByTestId](https://testing-library.com/docs/queries/bytestid). ## Refactor time Let’s try applying idea this to our application. Firstly, we’ll need to add `data-testid` attributes to the elements that we are interested in. 1. Create track button ```tsx ``` 2. Track form ```tsx
    ``` 3. Save button ```tsx Save ``` 4. Form error messages ```tsx ``` After these changes, tests for the flow can be updated like this: ```ts it("opens a create track modal", async () => { renderPage(); await expect .element(page.getByTestId("create-track-button")) .toBeInTheDocument(); await page.getByTestId("create-track-button").click(); await expect.element(page.getByTestId("track-form")).toBeInTheDocument(); }); it("should show errors when required fields are not filled", async () => { renderPage(); await page.getByTestId("create-track-button").click(); await page.getByTestId("submit-button").click(); await expect.element(page.getByTestId("error-title")).toBeInTheDocument(); await expect.element(page.getByTestId("error-artist")).toBeInTheDocument(); }); ``` That’s looking much better! I think it solves all of the problems that I’ve highlighted in the first approach: - It doesn’t matter what the text of the buttons are. We will still reliably select those buttons (given that they weren’t removed entirely 😅) - We are not depending on the dialog title anymore, we are just getting the form directly. - We are not depending on the text of the validation errors. We are only testing that they appear after validation. - We can even run the same set of tests in different languages! Because no matter the language, our business logic stays the same. But this approach has some downsides as well: - We are no longer asserting the accessible roles of the elements that we are selecting. For example, the element `create-track-button` could be anything, like div, with an `onClick` attached to it. - We are no longer asserting what these validation errors are, they could be entirely different from what we are expecting to see. - Both Playwright and Testing Library docs advise against using this technique. It should be our last resort, it should only be used when all other methods are not working. ## Conclusion `data-testid` attributes are not a silver bullet to solve all of my problems with UI tests. But when used wisely, they can help solve the problem of automating a large number of user flows that need to be performed. I think that I’ll be using this technique more in my work.