Skip to content

[OP-19666] Consolidate custom-element morph policy#24039

Draft
myabc wants to merge 7 commits into
code-maintenance/OP-19617-turbo-test-seamfrom
code-maintenance/OP-19666-morph-policy-consolidation
Draft

[OP-19666] Consolidate custom-element morph policy#24039
myabc wants to merge 7 commits into
code-maintenance/OP-19617-turbo-test-seamfrom
code-maintenance/OP-19666-morph-policy-consolidation

Conversation

@myabc

@myabc myabc commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Ticket

https://community.openproject.org/wp/OP-19666

Stacked on #23980 (OP-19617) — base branch is code-maintenance/OP-19617-turbo-test-seam, not dev. Merges after #23980.

What are you trying to accomplish?

OP-19666 is Part B of the Turbo cleanup consolidation started in OP-19618 (Part A, CSP nonce enforcement, already merged into this stack). Two independent files each re-implemented the same OPCE-* custom-element tag-name check to decide whether Turbo's idiomorph is allowed to morph an element: turbo-global-listeners.ts (a global preventDefault() guard) and dialog/preview.controller.ts (a frame-local beforeNodeMorphed callback with an extra scheduling-change exception). Neither the shared logic nor, in the controller's case, any of the surrounding morph behaviour had test coverage.

Investigation also found two other Turbo morph mechanisms that looked related but solve genuinely different problems — action-menu-morph-remount.ts (post-morph remount, not a skip) and the not-yet-merged PR #23218's pragmatic-dnd-morph-attributes.ts (attribute-level preservation, different event). Both are intentionally out of scope; forcing them into one shared interface would bend that interface to fit cases it doesn't describe.

What approach did you choose and why?

Three commits:

  1. Add an OpenProject custom element predicate. New frontend/src/turbo/openproject-custom-element.ts exports isOpenProjectCustomElement(node: Node): boolean — the single source of truth for "is this one of our custom elements." Signature takes Node, not Element, because idiomorph's morph callback can hand it non-Element nodes (e.g. Text nodes); the predicate guards internally with instanceof Element so neither call site needs its own defensive check. Purely additive, TDD, 3 tests.

  2. Route the morph guard through the shared predicate. turbo-global-listeners.ts swaps its inline check for the shared predicate, dropping a redundant .toUpperCase() call (Element.tagName is already uppercase). Behaviour-preserving; the two existing morph-guard tests (from OP-19617) are the regression net.

  3. Route the dialog morph guard through the shared predicate. DialogPreviewController (the abstract base shared by date-picker/ and progress/ preview controllers) swaps its inline check too. The scheduling-changed replace exception is untouched — it's date-picker domain logic, not generic morph policy. Adds coverage this path never had.

While building that new coverage, a genuine pre-existing bug surfaced: oldNode.replaceWith(newNode) inside the scheduling-changed branch detaches the old node from the DOM, which breaks idiomorph's sibling-walk bookkeeping — any DOM sibling following a replaced OPCE-* node is silently left stale. Confirmed pre-existing on dev (unrelated to this consolidation), traced against idiomorph's source, verified empirically. Filed separately as https://community.openproject.org/wp/OP-19669 rather than folded into this branch — the test for that path asserts only the (correct) OPCE-* replacement, with a comment pointing at the ticket for the known sibling-morph gap.

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)
myabc added 7 commits June 30, 2026 21:40
The Turbo integration listeners bound to the global `document` and
returned `void`, so the most Turbo-upgrade-fragile frontend code had no
unit coverage beyond a single Selenium navigation spec.

Threads an optional event target and `AbortSignal` through the
installers so vitest can drive synthetic `turbo:*` events and tear the
listeners down between cases. Adds specs for the seamed installers and
all five custom stream actions; callers stay unchanged through the
defaults.
The frame-visit monkeypatch reached into Turbo internals behind
`@ts-nocheck` and a blanket eslint-disable, so neither the compiler nor
a test would notice a Turbo upgrade renaming the method or fixing
hotwired/turbo#1300 underneath it.

Documents the single divergence from upstream -- sourcing the history
snapshot from the live document rather than the fetch response -- pins
it to Turbo 8.0.x, and adds a tripwire spec that fails on either upgrade
so the patch is re-evaluated instead of silently breaking.
The vitest tripwire guards the patched method's upstream shape but not
its runtime effect. This adds the behavioural half on the departments
page, whose content area is a `data-turbo-action="advance"` frame:
advancing through the tree and pressing back must restore the parent
frame.

Without `turbo-navigation-patch.ts` the URL reverts while the frame
keeps the child content, so the exact breadcrumb match fails. The bug
only reproduces with Rails-rendered frames, which is why it cannot live
in the vitest suite.
The wrapper destroys and re-bootstraps the whole Angular root on every
Turbo navigation -- the most consequential frontend/src/turbo/ installer
yet the only one left without coverage.

Threads the same (target, signal) seam and injects the plugin-context
lookup and bootstrap call so vitest can drive synthetic turbo:load
events. The RxJS skip(1) pipeline and destroy-then-bootstrap contract
are unchanged; callers stay on the defaults.
The OPCE-* tag-name check used to decide whether Turbo's idiomorph
may morph an element was duplicated in turbo-global-listeners.ts and
dialog/preview.controller.ts, with no test covering either. Gives
both a single, tested source: isOpenProjectCustomElement(node).

Additive only -- nothing calls this yet.
Replaces the inline OPCE-* tag check with isOpenProjectCustomElement,
dropping a redundant toUpperCase() call (Element.tagName is already
uppercase). Behaviour is unchanged -- the existing morph-guard specs
are the regression net for this swap.
Replaces DialogPreviewController's inline OPCE-* tag check with
isOpenProjectCustomElement, shared with turbo-global-listeners.ts.
The scheduling-changed replace exception is untouched -- it is
date-picker domain logic, not generic morph policy.

Wraps the date-picker preview spec's fixture in a <turbo-frame> and
adds the coverage this morph path never had: an OPCE-* node is
skipped or replaced correctly, a plain node always morphs when not
adjacent to a replaced OPCE-* node. A pre-existing bug where a
sibling following a replaced OPCE-* node is left stale is tracked
separately as OP-19669, not fixed here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Consolidates the “OPCE-* custom element” detection used by Turbo/Idiomorph morph guards into a single shared predicate, then routes the existing global and dialog-level morph policies through it, adding unit coverage for the new predicate and for the dialog morph behavior (date-picker preview).

Changes:

  • Added isOpenProjectCustomElement(node: Node) as the single predicate for OP custom elements.
  • Updated Turbo global turbo:before-morph-element prevention logic to use the shared predicate.
  • Updated dialog preview morph callback to use the shared predicate and added date-picker controller spec coverage for the OPCE morph behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
frontend/src/turbo/turbo-global-listeners.ts Uses shared predicate for the global Turbo morph preventDefault guard.
frontend/src/turbo/openproject-custom-element.ts Introduces shared isOpenProjectCustomElement(Node) predicate.
frontend/src/turbo/openproject-custom-element.spec.ts Adds vitest coverage for the shared predicate (Element/non-Element cases).
frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts Uses shared predicate in Idiomorph beforeNodeMorphed callback.
frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.spec.ts Adds coverage for dialog morph behavior (skip vs replace OPCE nodes).
Comment on lines +130 to +132
const turboFrame = ctx.container.querySelector('turbo-frame')!;
turboFrame.setAttribute('src', '/work_packages/123/dialog/preview');

Comment on lines +148 to +150
const turboFrame = ctx.container.querySelector('turbo-frame')!;
turboFrame.setAttribute('src', '/work_packages/123/dialog/preview?schedule_manually=true');

@myabc myabc force-pushed the code-maintenance/OP-19617-turbo-test-seam branch from b660548 to a6d0e55 Compare July 1, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants