InfoGrab Docs

Rapid Diffs

요약

Rapid Diffs는 GitLab을 위한 고성능 diff 렌더링 시스템입니다. [!NOTE] 머지 리퀘스트 페이지는 rapid_diffs_on_mr_show 피처 플래그가 필요합니다. Rapid Diffs의 주요 초점은 인지 성능, 즉 요청부터 화면의 첫 번째 렌더링된 diff까지의 시간을 최소화하는 것입니다.

Rapid Diffs는 GitLab을 위한 고성능 diff 렌더링 시스템입니다. Rapid Diffs는 서버 렌더링 HTML, Web Components, HTTP 스트리밍을 사용하여 머지 리퀘스트, 커밋, 비교 페이지에서 코드 변경 사항을 표시합니다. 이 접근 방식에 대한 이유는 기본적으로 지연을 참조하세요.

[!NOTE] 머지 리퀘스트 페이지는 rapid_diffs_on_mr_show 피처 플래그가 필요합니다.

서버 및 클라이언트 아키텍처#

Rapid Diffs의 주요 초점은 인지 성능, 즉 요청부터 화면의 첫 번째 렌더링된 diff까지의 시간을 최소화하는 것입니다.

Diff 파일은 항상 ViewComponent를 사용하여 서버에서 렌더링됩니다. 클라이언트는 diff HTML을 구성하지 않습니다. 클라이언트는 서버 렌더링 HTML을 페이지에 배치하고 인터랙티비티를 추가하기만 합니다. 이것은 모든 시나리오에 적용됩니다:

  • 초기 페이지 로드 시, 서버는 HTML 응답에 첫 번째 배치의 diff 파일을 인라인으로 렌더링합니다. 사용자는 즉시 콘텐츠를 봅니다.
  • 스트리밍 중, 나머지 diff 파일은 서버에서 스트리밍 HTML 응답으로 도착합니다. 클라이언트는 HTML을 있는 그대로 라이브 DOM에 렌더링합니다.
  • 리로드 시, 사용자가 인라인/병렬 또는 공백과 같은 뷰 설정을 변경하거나 통합이 리로드를 트리거할 때, 클라이언트는 전체 diffs 목록을 서버에서 스트리밍된 신선한 서버 렌더링 HTML로 교체합니다. 단일 파일은 HTML 엔드포인트를 가져와 해당 <diff-file> 요소를 교체하는 방식으로 개별적으로 리로드할 수도 있습니다.

JavaScript는 경량 어댑터 시스템을 통해서만 인터랙티비티를 추가합니다. 어댑터는 파일을 토글하고, 토론을 관리하며, 메뉴를 제어합니다.

Mermaid 다이어그램 (8줄)
소스 코드 보기
graph TD
  A[Rails Controller] --> B[Presenter]
  B --> C[AppComponent]
  C --> D[DiffFileComponent]
  D --> E[ViewerComponent]
  E --> F["HTML &lt;diff-file&gt;"]
  F --> G["&lt;diff-file-mounted&gt; fires"]
  G --> H[Adapters add interactivity]

Diff 용어#

Git은 파일 변경 사항을 diff로 나타냅니다. diff 파일은 단일 파일의 변경 사항을 나타냅니다. diff 파일은 파일 헤더와 하나 이상의 hunk로 구성됩니다. 각 hunk는 변경이 발생하는 위치를 나타내는 @@ 줄로 표시된 hunk 헤더로 시작합니다. hunk 헤더 다음에는 hunk 줄이 옵니다: 실제 추가, 제거, 변경되지 않은 컨텍스트 줄입니다.

다음 예시는 원시 git diff가 이러한 부분에 어떻게 매핑되는지 보여줍니다:

┌─ File header
│
│  diff --git a/app/models/user.rb b/app/models/user.rb
│  index 4a5e3f1..b7c9d2a 100644
│  --- a/app/models/user.rb
│  +++ b/app/models/user.rb
│
├─ Hunk 1
│  ┌─ Hunk header
│  │  @@ -10,6 +10,7 @@ class User < ApplicationRecord
│  │
│  ├─ Hunk lines
│  │    validates :name, presence: true       ← context (unchanged)
│  │    validates :email, presence: true      ← context (unchanged)
│  │  + validates :username, uniqueness: true ← added
│  │    validates :role, inclusion: ROLES     ← context (unchanged)
│  │
├─ Hunk 2
│  ┌─ Hunk header
│  │  @@ -25,7 +26,7 @@ class User < ApplicationRecord
│  │
│  ├─ Hunk lines
│  │    def display_name                     ← context
│  │  -   name                               ← removed
│  │  +   "#{name} (@#{username})"           ← added
│  │    end                                  ← context

Rapid Diffs에서 서버는 이러한 각 부분을 <diff-file> 웹 컴포넌트 내부의 HTML 테이블 행으로 렌더링합니다. Hunk 헤더는 인터랙티브합니다. 사용자는 클릭하여 hunk 위 또는 아래의 숨겨진 컨텍스트 줄을 확장합니다. Hunk 줄은 파일의 이전 버전과 새 버전 모두에 대한 줄 번호와 함께 구문 강조된 코드를 표시합니다.

서버 측#

서버 측 코드는 app/components/rapid_diffs/에 있습니다:

  • AppComponent는 루트 셸입니다. AppComponent는 헤더, 파일 브라우저 사이드바, diffs 목록을 렌더링합니다. AppComponentapp_data를 JSON 데이터 속성으로 클라이언트에 전달합니다.
  • DiffFileComponent는 단일 diff 파일을 <diff-file> 커스텀 요소 내부에 래핑합니다. DiffFileComponent는 올바른 뷰어를 선택하고 파일 메타데이터를 data-file-data로 제공합니다.
  • viewers/의 뷰어는 각각 특정 diff 유형을 렌더링합니다: 인라인 텍스트, 병렬 텍스트, 이미지, 미리보기 없음. 뷰어는 ViewerComponent를 확장하고 text_inline과 같은 문자열을 반환하는 self.viewer_name을 구현합니다. 이 이름은 서버 렌더링 파일을 일치하는 클라이언트 측 어댑터 구성에 매핑합니다.

프레젠터#

프레젠터는 AppComponent 및 diff 파일 엔드포인트를 위한 데이터를 준비합니다. 뷰 레이어를 컨트롤러에 결합하지 않고 엔드포인트 URL, 사용자 기본 설정 및 구성을 제공합니다. MergeRequestAppComponentCommitAppComponent와 같은 페이지별 앱 컴포넌트는 AppComponent를 래핑하고 추가 데이터를 주입합니다. 예를 들어, CommitAppComponent는 토론 엔드포인트를 추가합니다.

클라이언트로의 데이터 흐름#

서버는 세 레이어에서 HTML data-* 속성을 통해 클라이언트에 데이터를 전달합니다:

  • 루트 [data-rapid-diffs] 요소의 data-app-data. JSON으로 엔드포인트, 사용자 기본 설정 및 앱 구성을 포함합니다. 앱 초기화 시 한 번 파싱됩니다.
  • <diff-file> 요소의 data-file-data. 뷰어 이름, 파일 경로, diff 줄 엔드포인트를 포함합니다. 파일이 마운트될 때 한 번 파싱됩니다.
  • 줄 번호 또는 액션 식별자와 같은 작은 값에 대한 특정 인터랙티브 요소의 data-* 속성.

클라이언트는 쿠키를 통해 서버에 데이터를 다시 보냅니다. 쿠키는 파일 브라우저 가시성 및 사이드바 너비와 같은 UI 상태를 유지하여 서버가 이동 없이 올바른 초기 레이아웃을 렌더링할 수 있게 합니다.

클라이언트 측#

클라이언트 측 코드는 app/assets/javascripts/rapid_diffs/에 있습니다. 진입점은 app/index.jsRapidDiffsFacade이며, 앱을 초기화하고, 웹 컴포넌트를 등록하며, 스트리밍을 시작합니다. commit_app.jsCommitRapidDiffsApp과 같은 페이지별 서브클래스는 추가 설정으로 파사드를 확장합니다.

클라이언트는 세 레이어로 구성됩니다:

레이어 1, web_components/의 웹 컴포넌트: <diff-file><diff-file-mounted>. DOM 요소 라이프사이클을 소유합니다. 앱에 대한 참조를 저장하고, 내부 diff 요소를 캐시하며, 모든 이벤트를 어댑터에 위임합니다. 기능 로직이 없습니다.

레이어 2, adapters/의 어댑터: 라이프사이클 이벤트를 구독하고 기능 로직을 수행하는 상태 없는 JavaScript 객체. 어댑터 구성을 통해 뷰어 유형과 페이지별로 구성됩니다. 어댑터를 참조하세요.

레이어 3, app/stores/의 UI 컴포넌트: 토론, 이미지 뷰어, 옵션 메뉴와 같은 복잡한 인터랙티브 UI를 처리하는 Vue 컴포넌트와 Pinia 스토어. 어댑터는 이것들을 서버 렌더링된 플레이스홀더 요소에 마운트합니다. Vue 인스턴스를 생성하는 어댑터가 해당 인스턴스의 라이프사이클을 소유합니다. Pinia 스토어는 뷰 설정, 로드된 파일, 토론과 같은 전역 상태를 관리합니다. 어댑터와 Vue 컴포넌트 모두 Pinia 스토어에 접근할 수 있습니다.

Mermaid 다이어그램 (8줄)
소스 코드 보기
graph TD
  Server["Server (ViewComponent)"] -- "HTML + data attributes" --> L1["Layer 1: Web Components"]
  L1 -- "lifecycle events, clicks, adapter context" --> L2["Layer 2: Adapters"]
  L2 -- "mounts Vue into placeholder elements" --> L3["Layer 3: UI Components"]
  L2 -- "trigger events, update DOM" --> L1
  L3 -- "emit events" --> L2
  L2 -. "reads/writes" .-> Stores["Pinia Stores"]
  L3 -. "reads/writes" .-> Stores

<diff-file> 라이프사이클#

각 diff 파일은 web_components/diff_file.js에 정의된 <diff-file> 커스텀 요소입니다. 이 요소는 Shadow DOM을 사용하지 않습니다. 서버는 자식을 일반 HTML로 렌더링하고, 끝에 있는 센티넬 <diff-file-mounted> 요소가 모든 자식이 존재한다는 신호를 보냅니다.

마운트 시퀀스는 브라우저가 <diff-file-mounted>를 만날 때 시작됩니다:

  1. 요소의 connectedCallbackparentElement.mount(app)을 호출합니다.
  2. mount()는 앱에 대한 참조를 저장하고, diff 요소를 캐시하며, 가시성 관찰을 설정하고, MOUNTED 어댑터 이벤트를 트리거합니다.

이것은 커스텀 요소에 특정한 타이밍 문제를 해결합니다. 브라우저가 여는 <diff-file> 태그를 만날 때, DOM에 자식 요소가 존재하기 전에 요소를 생성하고 즉시 connectedCallback을 호출합니다. 스트리밍 중에는 자식이 점진적으로 도착합니다. 끝에 있는 <diff-file-mounted> 센티넬은 초기화가 실행되기 전에 모든 자식이 존재한다는 것을 보장합니다.

<diff-file>은 사용자가 뷰 설정을 변경할 때(예: 인라인에서 병렬로 전환) 또는 파일이 리로드될 때 파괴됩니다. 요소는 DOM에서 제거되고 모든 어댑터 정리 콜백이 실행됩니다. MOUNTED 이벤트는 파괴 시 실행할 정리 함수를 등록하는 onUnmounted 콜백을 받습니다. 정리 규칙은 어댑터 및 런타임을 참조하세요.

어댑터#

어댑터는 diff 파일에 동작을 추가하는 일반 JavaScript 객체입니다. <diff-file>이 마운트될 때, 요소는 data-file-data에서 뷰어 이름(예: text_inline 또는 image)을 읽고 app/adapter_configs/의 어댑터 구성에서 일치하는 어댑터 목록을 조회합니다. 해당 뷰어에 등록된 어댑터만 해당 파일에서 실행되므로 다른 파일 유형은 다른 동작을 얻습니다.

각 어댑터는 adapter_events.js에 선언된 라이프사이클 이벤트에 응답합니다: 마운팅, 클릭, 가시성 변경, 파일 확장/축소. 어댑터는 위임된 클릭 액션을 위한 clicks 객체도 선언합니다. diff 파일 템플릿은 인터랙티브 요소를 data-click="actionName"으로 표시하고, DiffFile은 클릭을 모든 어댑터의 일치하는 핸들러로 라우팅합니다.

어댑터 메서드 내부에서 this는 어댑터 컨텍스트에 리바인딩됩니다. this는 어댑터 객체를 참조하지 않습니다. 컨텍스트는 앱 데이터, diff DOM 요소, 파싱된 파일 메타데이터, sink 객체를 노출합니다. sink는 어댑터가 상태 없이 인스턴스 필드가 없기 때문에 존재합니다. 어댑터는 이벤트 사이의 중간 데이터를 this.sink에 저장합니다(예: 줄 링크가 다시 작성되었는지 추적하는 플래그). 전체 컨텍스트 API는 web_components/diff_file.js를 참조하세요.

앱 루트에 있는 단일 클릭 리스너가 모든 클릭을 캡처하고, 가장 가까운 <diff-file>을 찾아 이벤트를 일치하는 clicks 핸들러로 라우팅합니다. 단일 공유 IntersectionObserver가 해당 핸들러를 선언하는 어댑터에서 VISIBLE/INVISIBLE을 트리거합니다.

다음 어댑터는 이러한 패턴을 함께 보여줍니다:

import { MOUNTED, VISIBLE } from '~/rapid_diffs/adapter_events';

export const myAdapter = {
  [MOUNTED](onUnmounted) {
    // Set up resources; clean them up when the diff file is destroyed
    const handler = () => { /* ... */ };
    this.diffElement.addEventListener('input', handler);
    onUnmounted(() => this.diffElement.removeEventListener('input', handler));
  },
  [VISIBLE]() {
    // Runs each time the file scrolls into view
  },
  clicks: {
    myAction(event, button) {
      // Responds to elements with data-click="myAction"
    },
  },
};

스트리밍#

초기 페이지 로드 후, 나머지 diff 파일은 별도의 스트리밍 HTTP 요청을 통해 도착합니다. 클라이언트는 스트림을 다음과 같이 처리합니다:

  1. 스트림 URL을 fetch()하거나, startup_js에서 미리 로드된 요청을 재사용합니다.
  2. document.createHTMLDocument()로 숨겨진 문서를 만듭니다.
  3. 숨겨진 문서에서 document.write()를 호출하여 브라우저가 들어오는 HTML을 점진적으로 파싱합니다.
  4. <diff-file>이 파싱을 완료하면(<diff-file-mounted>로 신호), 요소를 라이브 DOM으로 마이그레이션합니다.

document.write()는 새 문서에서만 호출할 수 있으므로 클라이언트가 숨겨진 문서를 만듭니다. 이것은 스트리밍 HTML 응답을 DOM 파서에 직접 전달하는 유일한 방법입니다.

설계 원칙#

이러한 원칙은 Rapid Diffs의 모든 설계 결정을 형성합니다.

기본적으로 지연#

기본 메트릭은 첫 번째 diff 파일이 표시되는 시간: 탐색 후 사용자가 첫 번째 렌더링된 diff를 보는 순간입니다. 서버 측 렌더링이 표시 가능한 diff까지의 가장 짧은 경로입니다. 브라우저는 응답이 도착할 때 JavaScript 없이도 HTML을 칠합니다. 클라이언트 측 접근 방식은 JavaScript를 다운로드하고, 코드를 실행하고, 데이터를 가져오고, 무언가가 나타나기 전에 DOM을 빌드해야 합니다. 서버 렌더링 외에도, 페이지는 첫 번째 diff 파일을 표시하는 데 필요한 최소한의 작업만 수행합니다. 다른 모든 것은 지연되거나 결과가 필요할 때 준비되도록 일찍 시작됩니다:

  • 스트리밍: 사용자는 서버가 모든 diff 처리를 완료하기 전에 콘텐츠를 봐야 합니다. 첫 번째 배치는 페이지 응답에 인라인으로 제공됩니다. 나머지 파일은 별도로 스트리밍되므로 서버는 첫 번째 페인트를 차단하지 않습니다.
  • 지연 렌더링: 화면 밖의 diff 파일은 레이아웃이나 페인트 비용이 없어야 합니다. 서버 제공 행 수와 함께 content-visibility: auto는 사용자가 아직 스크롤하지 않은 파일을 렌더링하지 않고 공간을 예약합니다.
  • 지연 데이터 로딩: diff 파일 자체만 초기 응답에 속합니다. 파일 브라우저 사이드바, 토론, 옵션 메뉴와 같은 보조 UI는 중요한 렌더링 경로가 완료된 후에 로드됩니다.
  • 지연 초기화: 인터랙티브 컴포넌트는 미리 마운트되어서는 안 됩니다. 첫 번째 사용자 상호작용 시 마운팅은 파일 또는 줄 수에 관계없이 설정 비용을 일정하게 유지합니다.
  • 이벤트 위임: 요소별 리스너는 페이지의 줄과 파일 수에 따라 확장됩니다. 단일 위임된 클릭 리스너와 단일 공유 IntersectionObserver는 이벤트 설정을 O(1)로 유지합니다.
  • 메모리 효율성: diff 페이지는 수천 개의 줄이 있는 수백 개의 파일을 포함할 수 있습니다. 파일당 하나의 어댑터 인스턴스를 할당하면 불필요한 오버헤드가 발생합니다. 어댑터 유형당 단일 일반 객체를 재사용하고 언마운트 시 파일별 상태를 버리면 메모리가 표시 가능한 파일에 비례합니다.
  • 프리로딩: JavaScript 번들은 다운로드 및 실행에 시간이 걸립니다. HTML <head>의 시작 호출은 번들이 로드되기 전에 fetch() 요청을 실행하여 앱이 초기화될 때 스트리밍 응답이 이미 진행 중입니다.

상속 대신 컴포지션#

Rapid Diffs는 네 개의 페이지에서 실행됩니다: 커밋, 리비전 비교, 새 머지 리퀘스트, 머지 리퀘스트. 각 페이지에는 다른 레이아웃, 데이터 소스, 기능 요구 사항이 있습니다. 머지 리퀘스트 페이지에는 풍부한 인라인 토론과 코드 리뷰 도구가 있습니다. 커밋 페이지에는 기본 토론과 커밋별 액션이 있습니다. 비교 페이지는 둘 다 필요 없습니다. 시스템은 페이지별 로직이 경계를 넘어 누출되지 않고 이러한 차이를 지원해야 합니다.

컴포지션은 교차 페이지 문제를 해결합니다. 기능은 클래스 계층 구조에 구축되는 것이 아니라 작고 독립적인 조각으로 조립됩니다. 각 조각은 좁은 책임을 가집니다: 파일 토글, 줄 확장, 링크 다시 작성. 머지 리퀘스트 페이지에 추가된 기능은 공유 기본 클래스가 아닌 어댑터 구성을 통해 조각이 결합되기 때문에 커밋 페이지에 영향을 미치지 않습니다. 페이지별 앱 컴포넌트가 깊은 계층 구조를 확장하는 대신 AppComponent를 래핑하는 서버에도 동일한 패턴이 적용됩니다.

기능 추가#

아래의 각 레시피는 구체적인 코드 예시와 함께 일반적인 작업을 안내합니다.

클릭 액션 추가#

  1. HAML 템플릿의 관련 요소에 data-click="yourAction" 속성을 추가합니다.
  2. 기존 어댑터에 clicks.yourAction 메서드를 추가하거나, 새 어댑터를 만듭니다.
  3. 새 어댑터를 만든 경우, app/adapter_configs/의 관련 어댑터 구성에 등록합니다.

예를 들어, 헤더 템플릿의 파일 토글 버튼은 data-click="toggleFile"을 사용합니다:

= render Pajamas::ButtonComponent.new(
    button_options: { data: { click: 'toggleFile' } }
  )

toggleFileAdapter가 클릭을 처리합니다:

export const toggleFileAdapter = {
  clicks: {
    toggleFile(event, button) {
      const collapsed = this.diffElement.dataset.collapsed === 'true';
      if (collapsed) {
        expand.call(this);
      } else {
        collapse.call(this);
      }
    },
  },
};

어댑터 이벤트 추가#

  1. adapter_events.js에서 이벤트 이름을 내보냅니다.
  2. 내보낸 상수를 키로 사용하여 어댑터에 이벤트 핸들러를 추가합니다.
  3. this.trigger(EVENT_NAME, ...args)로 이벤트를 트리거합니다.

예를 들어, expandLinesAdapter는 새 diff 줄을 삽입한 후 EXPANDED_LINES를 트리거하여 lineLinkAdapter와 같은 다른 어댑터가 반응할 수 있게 합니다:

// In expandLinesAdapter, after inserting lines:
this.trigger(EXPANDED_LINES);

// In lineLinkAdapter, rewriting links on the newly inserted lines:
export const lineLinkAdapter = {
  [EXPANDED_LINES]() {
    handleAllLineLinks.call(this);
  },
};

뷰어 추가#

  1. app/components/rapid_diffs/viewers/ViewerComponent 서브클래스를 만듭니다. self.viewer_name을 구현합니다.
  2. DiffFileComponent#viewer_component에 뷰어 선택 로직을 추가합니다.
  3. 뷰어에 클라이언트 측 인터랙티비티가 필요한 경우 어댑터를 만듭니다.
  4. app/adapter_configs/base.js에 새 뷰어 이름을 등록합니다.

예를 들어, PDF 뷰어를 추가하려면:

# app/components/rapid_diffs/viewers/pdf_view_component.rb
module RapidDiffs
  module Viewers
    class PdfViewComponent < ViewerComponent
      def self.viewer_name
        'pdf'
      end
    end
  end
end

그런 다음 DiffFileComponent#viewer_component에서:

return Viewers::PdfViewComponent if @diff_file.pdf?

그리고 app/adapter_configs/base.js에 뷰어를 등록합니다:

export const VIEWER_ADAPTERS = {
  // ...existing viewers...
  pdf: [...HEADER_ADAPTERS, pdfAdapter],
};

페이지별 동작 추가#

  1. VIEWER_ADAPTERS를 스프레드하고 변경이 필요한 뷰어 유형을 재정의하는 app/adapter_configs/에 어댑터 구성 파일을 만듭니다.
  2. adapterConfig를 구성으로 설정하는 파사드 서브클래스를 만듭니다.
  3. 추가 데이터로 AppComponent를 래핑하는 페이지별 앱 컴포넌트를 만듭니다.

예를 들어, 커밋 페이지는 기본 옵션 메뉴 어댑터를 커밋별 액션이 포함된 어댑터로 교체하고 인라인 토론을 추가합니다:

// app/adapter_configs/commit.js
export const adapters = {
  ...VIEWER_ADAPTERS,
  text_inline: [
    ...VIEWER_ADAPTERS.text_inline.filter((a) => a !== optionsMenuAdapter),
    commitDiffsOptionsMenuAdapter,
    inlineDiscussionsAdapter,
  ],
};

전체 예시는 commit_app.jsCommitAppComponent를 참조하세요.

어댑터 내부에 Vue 마운트#

드롭다운, 토론, 이미지 뷰어에 Vue가 필요한 경우, 어댑터의 MOUNTED 핸들러에서 인스턴스를 마운트합니다. 서버에서 렌더링된 플레이스홀더 요소를 대상으로 합니다:

-# Server renders a mount point
%div{ data: { my_mount: true } }
import Vue from 'vue';
import { MOUNTED } from '~/rapid_diffs/adapter_events';
import MyComponent from './my_component.vue';

export const myAdapter = {
  [MOUNTED]() {
    new Vue({
      el: this.diffElement.querySelector('[data-my-mount]'),
      render: (h) => h(MyComponent, { props: { /* ... */ } }),
    });
  },
};

새 기능 테스트#

Ruby ViewComponent 스펙#

서버 측 컴포넌트는 render_inline으로 테스트됩니다:

  • 전체 마운트: render_inline은 전체 컴포넌트 트리를 테스트합니다.
  • 얕은 마운트: allow_next_instance_of는 플레이스홀더 HTML로 자식 컴포넌트를 스텁합니다. DiffFileComponent와 같은 복잡한 컴포넌트는 얕은 마운트를 선호합니다.
it "renders the viewer" do
  render_inline(described_class.new(diff_file: diff_file))
  expect(page).to have_selector("diff-file")
  expect(page).to have_selector("diff-file-mounted")
end

JavaScript 어댑터 및 스토어 스펙#

인라인 HTML로 실제 DiffFile 커스텀 요소를 마운트하고 위임된 이벤트를 직접 호출하여 어댑터를 테스트합니다:

beforeAll(() => {
  customElements.define('diff-file', DiffFile);
});

beforeEach(() => {
  document.body.innerHTML = `
    <diff-file data-file-data='${JSON.stringify({ viewer: 'text_inline' })}'>
      <div class="rd-diff-file"><!-- template --></div>

    </diff-file>
  `;
  document.querySelector('diff-file').mount({
    adapterConfig: { text_inline: [myAdapter] },
    appData: {},
    unobserve: jest.fn(),
  });
});

Rapid Diffs 가이드라인#

코드를 작성하기 전에 설계 원칙을 검토하세요.

캐싱#

<diff-file> 프래그먼트는 동일한 diff를 보는 모든 사용자에 대해 동일한 HTML을 생성해야 서버가 프래그먼트를 캐시하고 재사용할 수 있습니다.

  • DiffFileComponent, 뷰어, 헤더와 같은 Diff 파일 컴포넌트는 diff 콘텐츠에만 의존해야 합니다: 콘텐츠 SHA, 파일 경로, 줄 콘텐츠, 줄 번호, 뷰어 이름.
  • 권한, 기본 설정, 아바타 URL과 같은 사용자별 데이터는 개별 diff 파일 내부가 아닌 루트 요소의 data-app-data에 배치합니다.
  • diff 파일 HTML에 CSRF 토큰 또는 세션 상태와 같은 요청별 데이터를 포함하지 마세요.
  • 기능이 토론 스레드와 같이 diff 파일 내부에 사용자별 콘텐츠를 필요로 하는 경우, 마운트 후 클라이언트에서 콘텐츠를 로드합니다.
  • ViewComponent에 속성을 추가할 때, 값이 사용자별로 변경되는지 확인합니다. 값이 변경되면 해당 속성은 diff 파일 템플릿에 속하지 않습니다.

클라이언트-서버 분리#

  • 기능이 동일한 diff를 보는 모든 사용자에 대해 동일한 HTML을 생성하면 서버에서 기능을 렌더링합니다. 예를 들어, 구문 강조된 코드 줄, hunk 헤더, 파일 헤더는 서버 렌더링됩니다. 기능이 사용자 입력에 반응하거나 사용자별로 다른 경우 어댑터를 통해 클라이언트에서 처리합니다. 예를 들어, 인라인 토론, 파일 축소 토글, 옵션 메뉴, 줄 퍼마링크 다시 작성은 클라이언트 측입니다.
  • 전역 구성은 data-app-data에, 파일별 메타데이터는 data-file-data에, 작은 요소별 값은 개별 data-* 속성에 배치합니다. 전체 참조는 클라이언트로의 데이터 흐름을 참조하세요.
  • 탐색과 첫 번째 표시 가능한 diff 사이에 지연을 추가하지 마세요. 변경이 중요한 경로에 비용을 추가하는 경우 비용을 지연하거나 제거합니다.

HTML 및 스타일링#

  • diff 파일 템플릿 내에서 Tailwind 유틸리티 클래스를 사용하지 마세요. Tailwind 클래스가 있는 단일 diff 줄은 짧은 rd- 클래스 이름을 사용하는 줄보다 3-5배 클 수 있습니다. 수천 개의 줄에 걸쳐 이 차이는 중요합니다.
  • diff 파일 본문 내에 JSON 블롭을 포함하지 마세요. 한 번 파싱되는 <diff-file> 요소의 data-file-data와 작은 값에 대한 특정 요소의 data-* 속성을 사용합니다.
  • 깊이 중첩된 래퍼 요소를 피하세요. 수천 개의 줄에 걸쳐 곱해진 각 추가 <div>는 파싱 시간과 메모리에 측정 가능한 오버헤드를 추가합니다.
  • 레거시 스타일과의 충돌을 피하기 위해 모든 CSS 클래스에 rd- 접두사를 붙입니다.
  • 인라인 스타일을 피하세요. SCSS 페이지 번들에 스타일을 정의합니다.
  • 깊이 중첩된 선택자를 피하세요. 단일 레벨 클래스 정의를 선호합니다.
  • 스티키 헤더 및 사이드바 너비와 같은 페이지별 오프셋에 CSS 변수를 사용합니다. 컴포넌트 스타일이 아닌 페이지 번들에서 정의합니다.

어댑터 및 런타임#

  • 개별 요소에 리스너를 연결하지 마세요. 위임된 clicks 핸들러 또는 어댑터 라이프사이클 이벤트를 사용합니다.
  • 중요한 렌더링 경로 이후까지 비필수 작업을 지연합니다. 파일이 표시될 때 적용되는 작업에 VISIBLE/INVISIBLE 핸들러를 사용합니다.
  • 미리가 아닌 첫 번째 사용자 상호작용 시 복잡한 컴포넌트를 마운트합니다.
  • 중간 상태를 클로저가 아닌 this.sink에 저장합니다. 큰 DOM 참조를 캡처하는 클로저는 메모리 누수를 일으킵니다.
  • onUnmounted에서 모든 이벤트 리스너와 DOM 참조를 정리합니다. Pinia 스토어나 Vue 컴포넌트와 같이 어댑터 외부에 DOM 참조를 저장하는 경우 onUnmounted에서도 참조를 지웁니다. 이를 하지 않으면 분리된 DOM 트리가 메모리에 남습니다.
[MOUNTED](onUnmounted) {
  const handler = () => { /* ... */ };
  this.diffElement.addEventListener('input', handler);
  onUnmounted(() => {
    this.diffElement.removeEventListener('input', handler);
  });
},

접근성#

Rapid Diffs는 WCAG 2.1ATAG 2.0 가이드라인의 AA 레벨을 준수해야 합니다.

  • 이미지와 같은 비텍스트 diff 콘텐츠에 대한 텍스트 대안을 제공합니다.
  • 모든 인터랙티브 요소를 키보드로 조작할 수 있게 합니다. 파일 토글, 확장 컨트롤, 토론 스레드, 옵션 메뉴는 마우스를 필요로 하지 않아야 합니다.
  • 보조 기술 사용자가 파일, hunk, 토론 사이를 탐색할 수 있도록 시맨틱 HTML과 적절한 제목 계층 구조를 사용합니다.
  • 세션 간 뷰 모드, 공백 설정, 파일 브라우저 가시성과 같은 사용자 기본 설정을 유지합니다.
  • Pajamas 접근성 개발자 체크리스트를 따르세요.

Rapid Diffs

원문 보기
요약

Rapid Diffs는 GitLab을 위한 고성능 diff 렌더링 시스템입니다. [!NOTE] 머지 리퀘스트 페이지는 rapid_diffs_on_mr_show 피처 플래그가 필요합니다. Rapid Diffs의 주요 초점은 인지 성능, 즉 요청부터 화면의 첫 번째 렌더링된 diff까지의 시간을 최소화하는 것입니다.

Rapid Diffs는 GitLab을 위한 고성능 diff 렌더링 시스템입니다. Rapid Diffs는 서버 렌더링 HTML, Web Components, HTTP 스트리밍을 사용하여 머지 리퀘스트, 커밋, 비교 페이지에서 코드 변경 사항을 표시합니다. 이 접근 방식에 대한 이유는 기본적으로 지연을 참조하세요.

[!NOTE] 머지 리퀘스트 페이지는 rapid_diffs_on_mr_show 피처 플래그가 필요합니다.

서버 및 클라이언트 아키텍처#

Rapid Diffs의 주요 초점은 인지 성능, 즉 요청부터 화면의 첫 번째 렌더링된 diff까지의 시간을 최소화하는 것입니다.

Diff 파일은 항상 ViewComponent를 사용하여 서버에서 렌더링됩니다. 클라이언트는 diff HTML을 구성하지 않습니다. 클라이언트는 서버 렌더링 HTML을 페이지에 배치하고 인터랙티비티를 추가하기만 합니다. 이것은 모든 시나리오에 적용됩니다:

  • 초기 페이지 로드 시, 서버는 HTML 응답에 첫 번째 배치의 diff 파일을 인라인으로 렌더링합니다. 사용자는 즉시 콘텐츠를 봅니다.
  • 스트리밍 중, 나머지 diff 파일은 서버에서 스트리밍 HTML 응답으로 도착합니다. 클라이언트는 HTML을 있는 그대로 라이브 DOM에 렌더링합니다.
  • 리로드 시, 사용자가 인라인/병렬 또는 공백과 같은 뷰 설정을 변경하거나 통합이 리로드를 트리거할 때, 클라이언트는 전체 diffs 목록을 서버에서 스트리밍된 신선한 서버 렌더링 HTML로 교체합니다. 단일 파일은 HTML 엔드포인트를 가져와 해당 <diff-file> 요소를 교체하는 방식으로 개별적으로 리로드할 수도 있습니다.

JavaScript는 경량 어댑터 시스템을 통해서만 인터랙티비티를 추가합니다. 어댑터는 파일을 토글하고, 토론을 관리하며, 메뉴를 제어합니다.

Mermaid 다이어그램 (8줄)
소스 코드 보기
graph TD
  A[Rails Controller] --> B[Presenter]
  B --> C[AppComponent]
  C --> D[DiffFileComponent]
  D --> E[ViewerComponent]
  E --> F["HTML &lt;diff-file&gt;"]
  F --> G["&lt;diff-file-mounted&gt; fires"]
  G --> H[Adapters add interactivity]

Diff 용어#

Git은 파일 변경 사항을 diff로 나타냅니다. diff 파일은 단일 파일의 변경 사항을 나타냅니다. diff 파일은 파일 헤더와 하나 이상의 hunk로 구성됩니다. 각 hunk는 변경이 발생하는 위치를 나타내는 @@ 줄로 표시된 hunk 헤더로 시작합니다. hunk 헤더 다음에는 hunk 줄이 옵니다: 실제 추가, 제거, 변경되지 않은 컨텍스트 줄입니다.

다음 예시는 원시 git diff가 이러한 부분에 어떻게 매핑되는지 보여줍니다:

┌─ File header
│
│  diff --git a/app/models/user.rb b/app/models/user.rb
│  index 4a5e3f1..b7c9d2a 100644
│  --- a/app/models/user.rb
│  +++ b/app/models/user.rb
│
├─ Hunk 1
│  ┌─ Hunk header
│  │  @@ -10,6 +10,7 @@ class User < ApplicationRecord
│  │
│  ├─ Hunk lines
│  │    validates :name, presence: true       ← context (unchanged)
│  │    validates :email, presence: true      ← context (unchanged)
│  │  + validates :username, uniqueness: true ← added
│  │    validates :role, inclusion: ROLES     ← context (unchanged)
│  │
├─ Hunk 2
│  ┌─ Hunk header
│  │  @@ -25,7 +26,7 @@ class User < ApplicationRecord
│  │
│  ├─ Hunk lines
│  │    def display_name                     ← context
│  │  -   name                               ← removed
│  │  +   "#{name} (@#{username})"           ← added
│  │    end                                  ← context

Rapid Diffs에서 서버는 이러한 각 부분을 <diff-file> 웹 컴포넌트 내부의 HTML 테이블 행으로 렌더링합니다. Hunk 헤더는 인터랙티브합니다. 사용자는 클릭하여 hunk 위 또는 아래의 숨겨진 컨텍스트 줄을 확장합니다. Hunk 줄은 파일의 이전 버전과 새 버전 모두에 대한 줄 번호와 함께 구문 강조된 코드를 표시합니다.

서버 측#

서버 측 코드는 app/components/rapid_diffs/에 있습니다:

  • AppComponent는 루트 셸입니다. AppComponent는 헤더, 파일 브라우저 사이드바, diffs 목록을 렌더링합니다. AppComponentapp_data를 JSON 데이터 속성으로 클라이언트에 전달합니다.
  • DiffFileComponent는 단일 diff 파일을 <diff-file> 커스텀 요소 내부에 래핑합니다. DiffFileComponent는 올바른 뷰어를 선택하고 파일 메타데이터를 data-file-data로 제공합니다.
  • viewers/의 뷰어는 각각 특정 diff 유형을 렌더링합니다: 인라인 텍스트, 병렬 텍스트, 이미지, 미리보기 없음. 뷰어는 ViewerComponent를 확장하고 text_inline과 같은 문자열을 반환하는 self.viewer_name을 구현합니다. 이 이름은 서버 렌더링 파일을 일치하는 클라이언트 측 어댑터 구성에 매핑합니다.

프레젠터#

프레젠터는 AppComponent 및 diff 파일 엔드포인트를 위한 데이터를 준비합니다. 뷰 레이어를 컨트롤러에 결합하지 않고 엔드포인트 URL, 사용자 기본 설정 및 구성을 제공합니다. MergeRequestAppComponentCommitAppComponent와 같은 페이지별 앱 컴포넌트는 AppComponent를 래핑하고 추가 데이터를 주입합니다. 예를 들어, CommitAppComponent는 토론 엔드포인트를 추가합니다.

클라이언트로의 데이터 흐름#

서버는 세 레이어에서 HTML data-* 속성을 통해 클라이언트에 데이터를 전달합니다:

  • 루트 [data-rapid-diffs] 요소의 data-app-data. JSON으로 엔드포인트, 사용자 기본 설정 및 앱 구성을 포함합니다. 앱 초기화 시 한 번 파싱됩니다.
  • <diff-file> 요소의 data-file-data. 뷰어 이름, 파일 경로, diff 줄 엔드포인트를 포함합니다. 파일이 마운트될 때 한 번 파싱됩니다.
  • 줄 번호 또는 액션 식별자와 같은 작은 값에 대한 특정 인터랙티브 요소의 data-* 속성.

클라이언트는 쿠키를 통해 서버에 데이터를 다시 보냅니다. 쿠키는 파일 브라우저 가시성 및 사이드바 너비와 같은 UI 상태를 유지하여 서버가 이동 없이 올바른 초기 레이아웃을 렌더링할 수 있게 합니다.

클라이언트 측#

클라이언트 측 코드는 app/assets/javascripts/rapid_diffs/에 있습니다. 진입점은 app/index.jsRapidDiffsFacade이며, 앱을 초기화하고, 웹 컴포넌트를 등록하며, 스트리밍을 시작합니다. commit_app.jsCommitRapidDiffsApp과 같은 페이지별 서브클래스는 추가 설정으로 파사드를 확장합니다.

클라이언트는 세 레이어로 구성됩니다:

레이어 1, web_components/의 웹 컴포넌트: <diff-file><diff-file-mounted>. DOM 요소 라이프사이클을 소유합니다. 앱에 대한 참조를 저장하고, 내부 diff 요소를 캐시하며, 모든 이벤트를 어댑터에 위임합니다. 기능 로직이 없습니다.

레이어 2, adapters/의 어댑터: 라이프사이클 이벤트를 구독하고 기능 로직을 수행하는 상태 없는 JavaScript 객체. 어댑터 구성을 통해 뷰어 유형과 페이지별로 구성됩니다. 어댑터를 참조하세요.

레이어 3, app/stores/의 UI 컴포넌트: 토론, 이미지 뷰어, 옵션 메뉴와 같은 복잡한 인터랙티브 UI를 처리하는 Vue 컴포넌트와 Pinia 스토어. 어댑터는 이것들을 서버 렌더링된 플레이스홀더 요소에 마운트합니다. Vue 인스턴스를 생성하는 어댑터가 해당 인스턴스의 라이프사이클을 소유합니다. Pinia 스토어는 뷰 설정, 로드된 파일, 토론과 같은 전역 상태를 관리합니다. 어댑터와 Vue 컴포넌트 모두 Pinia 스토어에 접근할 수 있습니다.

Mermaid 다이어그램 (8줄)
소스 코드 보기
graph TD
  Server["Server (ViewComponent)"] -- "HTML + data attributes" --> L1["Layer 1: Web Components"]
  L1 -- "lifecycle events, clicks, adapter context" --> L2["Layer 2: Adapters"]
  L2 -- "mounts Vue into placeholder elements" --> L3["Layer 3: UI Components"]
  L2 -- "trigger events, update DOM" --> L1
  L3 -- "emit events" --> L2
  L2 -. "reads/writes" .-> Stores["Pinia Stores"]
  L3 -. "reads/writes" .-> Stores

<diff-file> 라이프사이클#

각 diff 파일은 web_components/diff_file.js에 정의된 <diff-file> 커스텀 요소입니다. 이 요소는 Shadow DOM을 사용하지 않습니다. 서버는 자식을 일반 HTML로 렌더링하고, 끝에 있는 센티넬 <diff-file-mounted> 요소가 모든 자식이 존재한다는 신호를 보냅니다.

마운트 시퀀스는 브라우저가 <diff-file-mounted>를 만날 때 시작됩니다:

  1. 요소의 connectedCallbackparentElement.mount(app)을 호출합니다.
  2. mount()는 앱에 대한 참조를 저장하고, diff 요소를 캐시하며, 가시성 관찰을 설정하고, MOUNTED 어댑터 이벤트를 트리거합니다.

이것은 커스텀 요소에 특정한 타이밍 문제를 해결합니다. 브라우저가 여는 <diff-file> 태그를 만날 때, DOM에 자식 요소가 존재하기 전에 요소를 생성하고 즉시 connectedCallback을 호출합니다. 스트리밍 중에는 자식이 점진적으로 도착합니다. 끝에 있는 <diff-file-mounted> 센티넬은 초기화가 실행되기 전에 모든 자식이 존재한다는 것을 보장합니다.

<diff-file>은 사용자가 뷰 설정을 변경할 때(예: 인라인에서 병렬로 전환) 또는 파일이 리로드될 때 파괴됩니다. 요소는 DOM에서 제거되고 모든 어댑터 정리 콜백이 실행됩니다. MOUNTED 이벤트는 파괴 시 실행할 정리 함수를 등록하는 onUnmounted 콜백을 받습니다. 정리 규칙은 어댑터 및 런타임을 참조하세요.

어댑터#

어댑터는 diff 파일에 동작을 추가하는 일반 JavaScript 객체입니다. <diff-file>이 마운트될 때, 요소는 data-file-data에서 뷰어 이름(예: text_inline 또는 image)을 읽고 app/adapter_configs/의 어댑터 구성에서 일치하는 어댑터 목록을 조회합니다. 해당 뷰어에 등록된 어댑터만 해당 파일에서 실행되므로 다른 파일 유형은 다른 동작을 얻습니다.

각 어댑터는 adapter_events.js에 선언된 라이프사이클 이벤트에 응답합니다: 마운팅, 클릭, 가시성 변경, 파일 확장/축소. 어댑터는 위임된 클릭 액션을 위한 clicks 객체도 선언합니다. diff 파일 템플릿은 인터랙티브 요소를 data-click="actionName"으로 표시하고, DiffFile은 클릭을 모든 어댑터의 일치하는 핸들러로 라우팅합니다.

어댑터 메서드 내부에서 this는 어댑터 컨텍스트에 리바인딩됩니다. this는 어댑터 객체를 참조하지 않습니다. 컨텍스트는 앱 데이터, diff DOM 요소, 파싱된 파일 메타데이터, sink 객체를 노출합니다. sink는 어댑터가 상태 없이 인스턴스 필드가 없기 때문에 존재합니다. 어댑터는 이벤트 사이의 중간 데이터를 this.sink에 저장합니다(예: 줄 링크가 다시 작성되었는지 추적하는 플래그). 전체 컨텍스트 API는 web_components/diff_file.js를 참조하세요.

앱 루트에 있는 단일 클릭 리스너가 모든 클릭을 캡처하고, 가장 가까운 <diff-file>을 찾아 이벤트를 일치하는 clicks 핸들러로 라우팅합니다. 단일 공유 IntersectionObserver가 해당 핸들러를 선언하는 어댑터에서 VISIBLE/INVISIBLE을 트리거합니다.

다음 어댑터는 이러한 패턴을 함께 보여줍니다:

import { MOUNTED, VISIBLE } from '~/rapid_diffs/adapter_events';

export const myAdapter = {
  [MOUNTED](onUnmounted) {
    // Set up resources; clean them up when the diff file is destroyed
    const handler = () => { /* ... */ };
    this.diffElement.addEventListener('input', handler);
    onUnmounted(() => this.diffElement.removeEventListener('input', handler));
  },
  [VISIBLE]() {
    // Runs each time the file scrolls into view
  },
  clicks: {
    myAction(event, button) {
      // Responds to elements with data-click="myAction"
    },
  },
};

스트리밍#

초기 페이지 로드 후, 나머지 diff 파일은 별도의 스트리밍 HTTP 요청을 통해 도착합니다. 클라이언트는 스트림을 다음과 같이 처리합니다:

  1. 스트림 URL을 fetch()하거나, startup_js에서 미리 로드된 요청을 재사용합니다.
  2. document.createHTMLDocument()로 숨겨진 문서를 만듭니다.
  3. 숨겨진 문서에서 document.write()를 호출하여 브라우저가 들어오는 HTML을 점진적으로 파싱합니다.
  4. <diff-file>이 파싱을 완료하면(<diff-file-mounted>로 신호), 요소를 라이브 DOM으로 마이그레이션합니다.

document.write()는 새 문서에서만 호출할 수 있으므로 클라이언트가 숨겨진 문서를 만듭니다. 이것은 스트리밍 HTML 응답을 DOM 파서에 직접 전달하는 유일한 방법입니다.

설계 원칙#

이러한 원칙은 Rapid Diffs의 모든 설계 결정을 형성합니다.

기본적으로 지연#

기본 메트릭은 첫 번째 diff 파일이 표시되는 시간: 탐색 후 사용자가 첫 번째 렌더링된 diff를 보는 순간입니다. 서버 측 렌더링이 표시 가능한 diff까지의 가장 짧은 경로입니다. 브라우저는 응답이 도착할 때 JavaScript 없이도 HTML을 칠합니다. 클라이언트 측 접근 방식은 JavaScript를 다운로드하고, 코드를 실행하고, 데이터를 가져오고, 무언가가 나타나기 전에 DOM을 빌드해야 합니다. 서버 렌더링 외에도, 페이지는 첫 번째 diff 파일을 표시하는 데 필요한 최소한의 작업만 수행합니다. 다른 모든 것은 지연되거나 결과가 필요할 때 준비되도록 일찍 시작됩니다:

  • 스트리밍: 사용자는 서버가 모든 diff 처리를 완료하기 전에 콘텐츠를 봐야 합니다. 첫 번째 배치는 페이지 응답에 인라인으로 제공됩니다. 나머지 파일은 별도로 스트리밍되므로 서버는 첫 번째 페인트를 차단하지 않습니다.
  • 지연 렌더링: 화면 밖의 diff 파일은 레이아웃이나 페인트 비용이 없어야 합니다. 서버 제공 행 수와 함께 content-visibility: auto는 사용자가 아직 스크롤하지 않은 파일을 렌더링하지 않고 공간을 예약합니다.
  • 지연 데이터 로딩: diff 파일 자체만 초기 응답에 속합니다. 파일 브라우저 사이드바, 토론, 옵션 메뉴와 같은 보조 UI는 중요한 렌더링 경로가 완료된 후에 로드됩니다.
  • 지연 초기화: 인터랙티브 컴포넌트는 미리 마운트되어서는 안 됩니다. 첫 번째 사용자 상호작용 시 마운팅은 파일 또는 줄 수에 관계없이 설정 비용을 일정하게 유지합니다.
  • 이벤트 위임: 요소별 리스너는 페이지의 줄과 파일 수에 따라 확장됩니다. 단일 위임된 클릭 리스너와 단일 공유 IntersectionObserver는 이벤트 설정을 O(1)로 유지합니다.
  • 메모리 효율성: diff 페이지는 수천 개의 줄이 있는 수백 개의 파일을 포함할 수 있습니다. 파일당 하나의 어댑터 인스턴스를 할당하면 불필요한 오버헤드가 발생합니다. 어댑터 유형당 단일 일반 객체를 재사용하고 언마운트 시 파일별 상태를 버리면 메모리가 표시 가능한 파일에 비례합니다.
  • 프리로딩: JavaScript 번들은 다운로드 및 실행에 시간이 걸립니다. HTML <head>의 시작 호출은 번들이 로드되기 전에 fetch() 요청을 실행하여 앱이 초기화될 때 스트리밍 응답이 이미 진행 중입니다.

상속 대신 컴포지션#

Rapid Diffs는 네 개의 페이지에서 실행됩니다: 커밋, 리비전 비교, 새 머지 리퀘스트, 머지 리퀘스트. 각 페이지에는 다른 레이아웃, 데이터 소스, 기능 요구 사항이 있습니다. 머지 리퀘스트 페이지에는 풍부한 인라인 토론과 코드 리뷰 도구가 있습니다. 커밋 페이지에는 기본 토론과 커밋별 액션이 있습니다. 비교 페이지는 둘 다 필요 없습니다. 시스템은 페이지별 로직이 경계를 넘어 누출되지 않고 이러한 차이를 지원해야 합니다.

컴포지션은 교차 페이지 문제를 해결합니다. 기능은 클래스 계층 구조에 구축되는 것이 아니라 작고 독립적인 조각으로 조립됩니다. 각 조각은 좁은 책임을 가집니다: 파일 토글, 줄 확장, 링크 다시 작성. 머지 리퀘스트 페이지에 추가된 기능은 공유 기본 클래스가 아닌 어댑터 구성을 통해 조각이 결합되기 때문에 커밋 페이지에 영향을 미치지 않습니다. 페이지별 앱 컴포넌트가 깊은 계층 구조를 확장하는 대신 AppComponent를 래핑하는 서버에도 동일한 패턴이 적용됩니다.

기능 추가#

아래의 각 레시피는 구체적인 코드 예시와 함께 일반적인 작업을 안내합니다.

클릭 액션 추가#

  1. HAML 템플릿의 관련 요소에 data-click="yourAction" 속성을 추가합니다.
  2. 기존 어댑터에 clicks.yourAction 메서드를 추가하거나, 새 어댑터를 만듭니다.
  3. 새 어댑터를 만든 경우, app/adapter_configs/의 관련 어댑터 구성에 등록합니다.

예를 들어, 헤더 템플릿의 파일 토글 버튼은 data-click="toggleFile"을 사용합니다:

= render Pajamas::ButtonComponent.new(
    button_options: { data: { click: 'toggleFile' } }
  )

toggleFileAdapter가 클릭을 처리합니다:

export const toggleFileAdapter = {
  clicks: {
    toggleFile(event, button) {
      const collapsed = this.diffElement.dataset.collapsed === 'true';
      if (collapsed) {
        expand.call(this);
      } else {
        collapse.call(this);
      }
    },
  },
};

어댑터 이벤트 추가#

  1. adapter_events.js에서 이벤트 이름을 내보냅니다.
  2. 내보낸 상수를 키로 사용하여 어댑터에 이벤트 핸들러를 추가합니다.
  3. this.trigger(EVENT_NAME, ...args)로 이벤트를 트리거합니다.

예를 들어, expandLinesAdapter는 새 diff 줄을 삽입한 후 EXPANDED_LINES를 트리거하여 lineLinkAdapter와 같은 다른 어댑터가 반응할 수 있게 합니다:

// In expandLinesAdapter, after inserting lines:
this.trigger(EXPANDED_LINES);

// In lineLinkAdapter, rewriting links on the newly inserted lines:
export const lineLinkAdapter = {
  [EXPANDED_LINES]() {
    handleAllLineLinks.call(this);
  },
};

뷰어 추가#

  1. app/components/rapid_diffs/viewers/ViewerComponent 서브클래스를 만듭니다. self.viewer_name을 구현합니다.
  2. DiffFileComponent#viewer_component에 뷰어 선택 로직을 추가합니다.
  3. 뷰어에 클라이언트 측 인터랙티비티가 필요한 경우 어댑터를 만듭니다.
  4. app/adapter_configs/base.js에 새 뷰어 이름을 등록합니다.

예를 들어, PDF 뷰어를 추가하려면:

# app/components/rapid_diffs/viewers/pdf_view_component.rb
module RapidDiffs
  module Viewers
    class PdfViewComponent < ViewerComponent
      def self.viewer_name
        'pdf'
      end
    end
  end
end

그런 다음 DiffFileComponent#viewer_component에서:

return Viewers::PdfViewComponent if @diff_file.pdf?

그리고 app/adapter_configs/base.js에 뷰어를 등록합니다:

export const VIEWER_ADAPTERS = {
  // ...existing viewers...
  pdf: [...HEADER_ADAPTERS, pdfAdapter],
};

페이지별 동작 추가#

  1. VIEWER_ADAPTERS를 스프레드하고 변경이 필요한 뷰어 유형을 재정의하는 app/adapter_configs/에 어댑터 구성 파일을 만듭니다.
  2. adapterConfig를 구성으로 설정하는 파사드 서브클래스를 만듭니다.
  3. 추가 데이터로 AppComponent를 래핑하는 페이지별 앱 컴포넌트를 만듭니다.

예를 들어, 커밋 페이지는 기본 옵션 메뉴 어댑터를 커밋별 액션이 포함된 어댑터로 교체하고 인라인 토론을 추가합니다:

// app/adapter_configs/commit.js
export const adapters = {
  ...VIEWER_ADAPTERS,
  text_inline: [
    ...VIEWER_ADAPTERS.text_inline.filter((a) => a !== optionsMenuAdapter),
    commitDiffsOptionsMenuAdapter,
    inlineDiscussionsAdapter,
  ],
};

전체 예시는 commit_app.jsCommitAppComponent를 참조하세요.

어댑터 내부에 Vue 마운트#

드롭다운, 토론, 이미지 뷰어에 Vue가 필요한 경우, 어댑터의 MOUNTED 핸들러에서 인스턴스를 마운트합니다. 서버에서 렌더링된 플레이스홀더 요소를 대상으로 합니다:

-# Server renders a mount point
%div{ data: { my_mount: true } }
import Vue from 'vue';
import { MOUNTED } from '~/rapid_diffs/adapter_events';
import MyComponent from './my_component.vue';

export const myAdapter = {
  [MOUNTED]() {
    new Vue({
      el: this.diffElement.querySelector('[data-my-mount]'),
      render: (h) => h(MyComponent, { props: { /* ... */ } }),
    });
  },
};

새 기능 테스트#

Ruby ViewComponent 스펙#

서버 측 컴포넌트는 render_inline으로 테스트됩니다:

  • 전체 마운트: render_inline은 전체 컴포넌트 트리를 테스트합니다.
  • 얕은 마운트: allow_next_instance_of는 플레이스홀더 HTML로 자식 컴포넌트를 스텁합니다. DiffFileComponent와 같은 복잡한 컴포넌트는 얕은 마운트를 선호합니다.
it "renders the viewer" do
  render_inline(described_class.new(diff_file: diff_file))
  expect(page).to have_selector("diff-file")
  expect(page).to have_selector("diff-file-mounted")
end

JavaScript 어댑터 및 스토어 스펙#

인라인 HTML로 실제 DiffFile 커스텀 요소를 마운트하고 위임된 이벤트를 직접 호출하여 어댑터를 테스트합니다:

beforeAll(() => {
  customElements.define('diff-file', DiffFile);
});

beforeEach(() => {
  document.body.innerHTML = `
    <diff-file data-file-data='${JSON.stringify({ viewer: 'text_inline' })}'>
      <div class="rd-diff-file"><!-- template --></div>

    </diff-file>
  `;
  document.querySelector('diff-file').mount({
    adapterConfig: { text_inline: [myAdapter] },
    appData: {},
    unobserve: jest.fn(),
  });
});

Rapid Diffs 가이드라인#

코드를 작성하기 전에 설계 원칙을 검토하세요.

캐싱#

<diff-file> 프래그먼트는 동일한 diff를 보는 모든 사용자에 대해 동일한 HTML을 생성해야 서버가 프래그먼트를 캐시하고 재사용할 수 있습니다.

  • DiffFileComponent, 뷰어, 헤더와 같은 Diff 파일 컴포넌트는 diff 콘텐츠에만 의존해야 합니다: 콘텐츠 SHA, 파일 경로, 줄 콘텐츠, 줄 번호, 뷰어 이름.
  • 권한, 기본 설정, 아바타 URL과 같은 사용자별 데이터는 개별 diff 파일 내부가 아닌 루트 요소의 data-app-data에 배치합니다.
  • diff 파일 HTML에 CSRF 토큰 또는 세션 상태와 같은 요청별 데이터를 포함하지 마세요.
  • 기능이 토론 스레드와 같이 diff 파일 내부에 사용자별 콘텐츠를 필요로 하는 경우, 마운트 후 클라이언트에서 콘텐츠를 로드합니다.
  • ViewComponent에 속성을 추가할 때, 값이 사용자별로 변경되는지 확인합니다. 값이 변경되면 해당 속성은 diff 파일 템플릿에 속하지 않습니다.

클라이언트-서버 분리#

  • 기능이 동일한 diff를 보는 모든 사용자에 대해 동일한 HTML을 생성하면 서버에서 기능을 렌더링합니다. 예를 들어, 구문 강조된 코드 줄, hunk 헤더, 파일 헤더는 서버 렌더링됩니다. 기능이 사용자 입력에 반응하거나 사용자별로 다른 경우 어댑터를 통해 클라이언트에서 처리합니다. 예를 들어, 인라인 토론, 파일 축소 토글, 옵션 메뉴, 줄 퍼마링크 다시 작성은 클라이언트 측입니다.
  • 전역 구성은 data-app-data에, 파일별 메타데이터는 data-file-data에, 작은 요소별 값은 개별 data-* 속성에 배치합니다. 전체 참조는 클라이언트로의 데이터 흐름을 참조하세요.
  • 탐색과 첫 번째 표시 가능한 diff 사이에 지연을 추가하지 마세요. 변경이 중요한 경로에 비용을 추가하는 경우 비용을 지연하거나 제거합니다.

HTML 및 스타일링#

  • diff 파일 템플릿 내에서 Tailwind 유틸리티 클래스를 사용하지 마세요. Tailwind 클래스가 있는 단일 diff 줄은 짧은 rd- 클래스 이름을 사용하는 줄보다 3-5배 클 수 있습니다. 수천 개의 줄에 걸쳐 이 차이는 중요합니다.
  • diff 파일 본문 내에 JSON 블롭을 포함하지 마세요. 한 번 파싱되는 <diff-file> 요소의 data-file-data와 작은 값에 대한 특정 요소의 data-* 속성을 사용합니다.
  • 깊이 중첩된 래퍼 요소를 피하세요. 수천 개의 줄에 걸쳐 곱해진 각 추가 <div>는 파싱 시간과 메모리에 측정 가능한 오버헤드를 추가합니다.
  • 레거시 스타일과의 충돌을 피하기 위해 모든 CSS 클래스에 rd- 접두사를 붙입니다.
  • 인라인 스타일을 피하세요. SCSS 페이지 번들에 스타일을 정의합니다.
  • 깊이 중첩된 선택자를 피하세요. 단일 레벨 클래스 정의를 선호합니다.
  • 스티키 헤더 및 사이드바 너비와 같은 페이지별 오프셋에 CSS 변수를 사용합니다. 컴포넌트 스타일이 아닌 페이지 번들에서 정의합니다.

어댑터 및 런타임#

  • 개별 요소에 리스너를 연결하지 마세요. 위임된 clicks 핸들러 또는 어댑터 라이프사이클 이벤트를 사용합니다.
  • 중요한 렌더링 경로 이후까지 비필수 작업을 지연합니다. 파일이 표시될 때 적용되는 작업에 VISIBLE/INVISIBLE 핸들러를 사용합니다.
  • 미리가 아닌 첫 번째 사용자 상호작용 시 복잡한 컴포넌트를 마운트합니다.
  • 중간 상태를 클로저가 아닌 this.sink에 저장합니다. 큰 DOM 참조를 캡처하는 클로저는 메모리 누수를 일으킵니다.
  • onUnmounted에서 모든 이벤트 리스너와 DOM 참조를 정리합니다. Pinia 스토어나 Vue 컴포넌트와 같이 어댑터 외부에 DOM 참조를 저장하는 경우 onUnmounted에서도 참조를 지웁니다. 이를 하지 않으면 분리된 DOM 트리가 메모리에 남습니다.
[MOUNTED](onUnmounted) {
  const handler = () => { /* ... */ };
  this.diffElement.addEventListener('input', handler);
  onUnmounted(() => {
    this.diffElement.removeEventListener('input', handler);
  });
},

접근성#

Rapid Diffs는 WCAG 2.1ATAG 2.0 가이드라인의 AA 레벨을 준수해야 합니다.

  • 이미지와 같은 비텍스트 diff 콘텐츠에 대한 텍스트 대안을 제공합니다.
  • 모든 인터랙티브 요소를 키보드로 조작할 수 있게 합니다. 파일 토글, 확장 컨트롤, 토론 스레드, 옵션 메뉴는 마우스를 필요로 하지 않아야 합니다.
  • 보조 기술 사용자가 파일, hunk, 토론 사이를 탐색할 수 있도록 시맨틱 HTML과 적절한 제목 계층 구조를 사용합니다.
  • 세션 간 뷰 모드, 공백 설정, 파일 브라우저 가시성과 같은 사용자 기본 설정을 유지합니다.
  • Pajamas 접근성 개발자 체크리스트를 따르세요.